Speed & Loop (userscript)

Control HTML5 video speed and AB loop actions.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Advertisement:

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

Advertisement:

// ==UserScript==
// @name         Speed & Loop (userscript)
// @namespace    https://github.com/grad13/Speed-and-Loop
// @version      2.1.48
// @description  Control HTML5 video speed and AB loop actions.
// @author       speed-and-loop
// @license      MIT
// @homepageURL  https://greasyfork.org/en/scripts/583175-speed-loop-userscript
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @run-at       document-idle
// ==/UserScript==

(function () {
  function injectStyle(id, css) {
    if (!document || !document.head || document.getElementById(id)) { return; }
    var style = document.createElement("style");
    style.id = id;
    style.textContent = css;
    document.head.appendChild(style);
  }
  injectStyle("psl-userscript-content-css", ".mpc-nosource {\n  display: none !important;\n}\n.mpc-manual {\n  visibility: visible !important;\n  opacity: 1 !important;\n}\n\n.mpc-controller {\n  /* In case of pages using `white-space: pre-line` (eg Discord), don't render vsc's whitespace */\n  white-space: normal;\n}\n\n/* Origin specific overrides */\n/* YouTube player */\n.ytp-hide-info-bar .mpc-controller {\n  position: relative;\n  top: 10px;\n}\n\n.ytp-autohide .mpc-controller {\n  visibility: visible;\n  opacity: 1;\n}\n\n.ytp-autohide .mpc-show {\n  visibility: visible;\n  opacity: 1;\n}\n\n/* YouTube embedded player */\n/* e.g. https://www.igvita.com/2012/09/12/web-fonts-performance-making-pretty-fast/ */\n.html5-video-player:not(.ytp-hide-info-bar) .mpc-controller {\n  position: relative;\n  top: 60px;\n}\n\n/* Facebook player */\n#facebook .mpc-controller {\n  position: relative;\n  top: 40px;\n}\n\n/* Google Photos player */\n/* Inline preview doesn't have any additional hooks, relying on Aria label */\na[aria-label^=\"Video\"] .mpc-controller {\n  position: relative;\n  top: 35px;\n}\n/* Google Photos full-screen view */\n#player .house-brand .mpc-controller {\n  position: relative;\n  top: 50px;\n}\n\n/* Netflix player */\n#netflix-player:not(.player-cinema-mode) .mpc-controller {\n  position: relative;\n  top: 85px;\n}\n\n/* shift controller on vine.co */\n/* e.g. https://vine.co/v/OrJj39YlL57 */\n.video-container .vine-video-container .mpc-controller {\n  margin-left: 40px;\n}\n\n/* shift YT 3D controller down */\n/* e.g. https://www.youtube.com/watch?v=erftYPflJzQ */\n.ytp-webgl-spherical-control {\n  top: 60px !important;\n}\n\n.ytp-fullscreen .ytp-webgl-spherical-control {\n  top: 100px !important;\n}\n\n/* disable Vimeo video overlay */\ndiv.video-wrapper + div.target {\n  height: 0;\n}\n\n/* Fix black overlay on Kickstarter */\ndiv.video-player.has_played.vertically_center:before,\ndiv.legacy-video-player.has_played.vertically_center:before {\n  content: none !important;\n}\n\n/* Fix black overlay on openai.com */\n.Shared-Video-player > .mpc-controller {\n  height: 0;\n}\n");

// ---- code/extension/content/namespace.js ----
(function (global) {
  var PSL = global.PlaySpeedLoop || {};
  var pendingInjectionSessionId = global.__PSL_PENDING_INJECTION_SESSION_ID;

  PSL.regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;

  // S0 (documents/plan/2026-06-26-content-runtime-singleton.md): a second
  // execution of this bundle in the SAME world (double injection) must not mint
  // a new instanceId. Otherwise a controller attached before the overwrite and
  // one attached after carry different _pslInstanceId and both survive on the
  // same media while idle (no rescan fires) — the observed duplicate panels.
  // Reuse the existing instanceId unless S0 is explicitly disabled for
  // fix-attribution testing (localStorage PSL_FIX_S0 === "0").
  //
  // injectionSessionId is intentionally NOT made idempotent: it tracks the
  // background injection session so that a genuinely new session (e.g. the
  // add-on re-enabled) still re-initializes via the init.js guard. In a
  // same-session double injection the pending session id is identical anyway, so
  // leaving this as-is does not contribute to the duplicate.
  var s0Disabled = false;
  try {
    s0Disabled = !!(
      global.localStorage && global.localStorage.getItem("PSL_FIX_S0") === "0"
    );
  } catch (e) {}
  PSL.instanceId =
    (!s0Disabled && PSL.instanceId) ||
    "psl-" + Date.now() + "-" + Math.random().toString(36).slice(2);
  PSL.injectionSessionId =
    typeof pendingInjectionSessionId === "string"
      ? pendingInjectionSessionId
      : PSL.instanceId;

  // Stamp every diagnostic line with the extension version so captured logs are
  // self-identifying: no need to cross-check manifest/version after the fact to
  // know which build produced a capture (avoids the version-mismatch confusion
  // between plan docs). Read from the manifest; null if unavailable.
  PSL.version = (function () {
    try {
      var rt =
        (global.chrome && global.chrome.runtime) ||
        (global.browser && global.browser.runtime);
      if (rt && typeof rt.getManifest === "function") {
        return rt.getManifest().version;
      }
    } catch (e) {}
    return null;
  })();

  PSL.__injectionSessionId = PSL.injectionSessionId;
  // A re-evaluation of this bundle in the SAME world (double injection within one
  // background session) rebuilds PSL.state from scratch. The tab-scope maintained
  // speed lives ONLY here (PSL.state.tabSpeed) — no storage, no frame sync — so an
  // unconditional rebuild silently drops it: video.playbackRate (a DOM property)
  // survives the re-eval but tabSpeed becomes undefined, and the next relative
  // adjustment resolves its base from global=1 instead of the maintained speed.
  // Carry tabSpeed across the rebuild, the same way instanceId is preserved in S0
  // above. tabSpeed can be negative (reverse, kept across navigation per SC-28)
  // but is never 0 (speed-actions.js guards speedNumber !== 0), so accept any
  // finite number. isFiniteNumber is local to speed-state.js and out of scope
  // here, so use a plain typeof/isFinite check.
  var prevState = PSL.state;
  PSL.state = {
    mediaElements: [],
    startTimes: {},
    endTimes: {},
    pendingLoopPoints: {},
    loopsEnabled: {},
    controllerTimer: null,
    nextMediaId: 1,
    instanceId: PSL.instanceId
  };
  if (
    prevState &&
    typeof prevState.tabSpeed === "number" &&
    isFinite(prevState.tabSpeed)
  ) {
    PSL.state.tabSpeed = prevState.tabSpeed;
  }

  PSL.requestIdle = function (callback, options) {
    if (typeof global.requestIdleCallback === "function") {
      return global.requestIdleCallback.call(global, callback, options);
    }
    return global.setTimeout(function () {
      callback({ didTimeout: false, timeRemaining: function () { return 0; } });
    }, 0);
  };

  global.PlaySpeedLoop = PSL;
  try {
    delete global.__PSL_PENDING_INJECTION_SESSION_ID;
  } catch (e) {
    global.__PSL_PENDING_INJECTION_SESSION_ID = undefined;
  }

  function getGlobalStorage() {
    if (typeof browser !== "undefined" && browser.storage && browser.storage.sync) {
      return browser.storage.sync;
    }
    if (typeof chrome !== "undefined" && chrome.storage && chrome.storage.sync) {
      return chrome.storage.sync;
    }
    return null;
  }

  PSL.getAssetUrl = function (path) {
    try {
      var rt = global.chrome && global.chrome.runtime;
      if (rt && typeof rt.getURL === "function") {
        return rt.getURL(path);
      }
    } catch (e) {}

    try {
      var browserRuntime = global.browser && global.browser.runtime;
      if (browserRuntime && typeof browserRuntime.getURL === "function") {
        return browserRuntime.getURL(path);
      }
    } catch (e) {}

    return null;
  };

  PSL.getStorageBridge = function () {
    return getGlobalStorage();
  };

})(globalThis);

// ---- code/extension/content/runtime-ownership.js ----
(function (PSL) {
  // S1/S2 (documents/plan/2026-06-26-content-runtime-singleton.md): the current
  // content runtime claims ownership of the document by stamping its instanceId
  // on documentElement. When two runtimes coexist (extension reload / background
  // restart leaving two generations), the last to claim wins; the others become
  // non-owners and their controllers are swept by owner-based reconcile (S2).
  //
  // Safety net only: with S0 in place a second instanceId is not minted in the
  // first place, so in the single-runtime case the owner marker equals the
  // current instanceId and ownership is a no-op relative to the pre-S2 behavior.
  //
  // Gated for fix-attribution testing via localStorage (default on):
  //   PSL_FIX_S1 === "0"  -> do not claim ownership.
  //   PSL_FIX_S2 === "0"  -> reconcile ignores the marker (pre-S2 behavior).
  function flagDisabled(name) {
    try {
      return !!(
        typeof localStorage !== "undefined" &&
        localStorage.getItem(name) === "0"
      );
    } catch (e) {
      return false;
    }
  }

  function ownerElement(doc) {
    var d = doc || (typeof document !== "undefined" ? document : null);
    return d ? d.documentElement || null : null;
  }

  PSL.claimRuntimeOwnership = function (doc) {
    if (flagDisabled("PSL_FIX_S1")) {
      return;
    }
    var el = ownerElement(doc);
    var existing = el && el.dataset ? el.dataset.pslRuntimeOwner : null;
    if (el && el.dataset && !existing) {
      // First-writer-wins: a second content-script world that shares this
      // document must NOT steal ownership, or the owner marker would flip and
      // both worlds' panels would survive. The first runtime to claim owns the
      // document; later runtimes stay non-owners and do not attach panels.
      el.dataset.pslRuntimeOwner = PSL.instanceId;
    }
  };

  // The instanceId that currently owns the document. With S2 enabled this is the
  // owner marker, falling back to the current instanceId when unset
  // (single-runtime / back-compat). With S2 disabled it is always the current
  // instanceId — the pre-S2 behavior.
  PSL.getRuntimeOwner = function (doc) {
    if (flagDisabled("PSL_FIX_S2")) {
      return PSL.instanceId;
    }
    var el = ownerElement(doc);
    var marker = el && el.dataset ? el.dataset.pslRuntimeOwner : null;
    return marker || PSL.instanceId;
  };

  PSL.isRuntimeOwner = function (doc) {
    return PSL.getRuntimeOwner(doc) === PSL.instanceId;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/defaults.js ----
(function (PSL) {
  PSL.defaultKeyBindings = [
    { action: "slower", key: 83, value: 0.5, force: false, predefined: true },
    { action: "faster", key: 68, value: 0.5, force: false, predefined: true },
    { action: "reset", key: 82, value: 1, force: false, predefined: true },
    { action: "mark-loop-range", key: 65, value: 0, force: false, predefined: true },
    { action: "clear-loop", key: 69, value: 0, force: false, predefined: true }
  ];

  PSL.defaultSettings = {
    lastSpeed: 1.0,
    enabled: true,
    speeds: {},
    domainSpeeds: {},
    displayKeyCode: 86,
    audioBoolean: false,
    speedControlsEnabled: true,
    loopControlsEnabled: true,
    speedStep: 0.5,
    preferredSpeed: 1.8,
    reverseFrameRate: 1,
    speedScope: "tab",
    keepSpeedSites: "",
    controllerOpacity: 0.96,
    keyBindings: PSL.defaultKeyBindings,
    blacklist: "www.instagram.com\ntwitter.com\nvine.co\nimgur.com\nteams.microsoft.com",
    defaultLogLevel: 4,
    logLevel: 1
  };

  PSL.SPEED_SCOPES = ["tab", "domain", "video"];

  // Round any stored/legacy value to a supported scope. Unknown/invalid values
  // fall back to the default "tab" (see plan: speedScope single source of truth).
  PSL.normalizeSpeedScope = function (value) {
    return PSL.SPEED_SCOPES.indexOf(value) >= 0 ? value : "tab";
  };

  PSL.cloneDefaultSettings = function () {
    var settings = Object.assign({}, PSL.defaultSettings);
    settings.speeds = {};
    settings.domainSpeeds = {};
    settings.keyBindings = PSL.defaultKeyBindings.map(function (binding) {
      return Object.assign({}, binding);
    });
    return settings;
  };

  PSL.settings = PSL.cloneDefaultSettings();
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/logger.js ----
(function (PSL) {
  PSL.log = function (message, level) {
    var verbosity = PSL.settings.logLevel;
    if (typeof level === "undefined") {
      level = PSL.settings.defaultLogLevel;
    }
    if (verbosity >= level) {
      if (level === 2) {
        console.log("ERROR:" + message);
      } else if (level === 3) {
        console.log("WARNING:" + message);
      } else if (level === 4) {
        console.log("INFO:" + message);
      } else if (level === 5) {
        console.log("DEBUG:" + message);
      } else if (level === 6) {
        console.log("DEBUG (VERBOSE):" + message);
        console.trace();
      }
    }
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/unavailable-content-settings-storage.js ----
(function (PSL) {
  function logUnavailable(operation) {
    if (typeof PSL.log === "function") {
      PSL.log("Content settings storage unavailable during " + operation, 3);
    }
  }

  PSL.createUnavailableContentSettingsStorage = function () {
    return {
      get: function (defaults, callback) {
        logUnavailable("get");
        callback(null, defaults || {});
      },
      set: function (_values, callback) {
        logUnavailable("set");
        if (callback) {
          callback();
        }
      },
      remove: function (_keys, callback) {
        logUnavailable("remove");
        if (callback) {
          callback();
        }
      }
    };
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/content-settings-storage-backend.js ----
(function (PSL) {
  // Callback error contract (all platforms conform):
  //   get(defaults, callback) -> callback(error, result)
  //     success: callback(null, result); failure: callback(error, defaults)
  //   set(values, callback)   -> callback(error)
  //     success: callback();          failure: callback(error)
  //   remove(keys, callback)  -> callback(error)
  // A write/read failure is never reported as success. Callers can distinguish
  // a genuine failure from "no stored value" by inspecting the error argument.
  function hasBrowserStorage() {
    return (
      typeof browser !== "undefined" &&
      browser.storage &&
      browser.storage.sync
    );
  }

  function hasChromeStorage() {
    return (
      typeof chrome !== "undefined" &&
      chrome.storage &&
      chrome.storage.sync
    );
  }

  function hasGMStorage() {
    return typeof GM_getValue === "function" && typeof GM_setValue === "function";
  }

  function parseGMValue(rawValue, fallback) {
    if (typeof rawValue === "undefined" || rawValue === null) {
      return fallback;
    }

    if (typeof rawValue === "string") {
      try {
        return JSON.parse(rawValue);
      } catch (error) {
        return rawValue;
      }
    }

    return rawValue;
  }

  var platform;
  if (hasBrowserStorage()) {
    platform = {
      get: function (defaults, callback) {
        // null/undefined defaults => read the entire store (get(null)). Coercing
        // to {} would ask the backend for "no keys" and come back empty, which is
        // how a domain speed set on video 1 was lost on the next read.
        var query = defaults == null ? null : defaults;
        browser.storage.sync.get(query).then(
          function (result) {
            callback(null, result);
          },
          function (error) {
            callback(error, defaults || {});
          }
        );
      },
      set: function (values, callback) {
        browser.storage.sync.set(values).then(
          function () {
            if (callback) {
              callback();
            }
          },
          function (error) {
            if (callback) {
              callback(error);
            }
          }
        );
      },
      remove: function (keys, callback) {
        browser.storage.sync.remove(keys).then(
          function () {
            if (callback) {
              callback();
            }
          },
          function (error) {
            if (callback) {
              callback(error);
            }
          }
        );
      }
    };
  } else if (hasChromeStorage()) {
    platform = {
      get: function (defaults, callback) {
        // See the browser branch: null/undefined defaults => full read (get(null)).
        var query = defaults == null ? null : defaults;
        chrome.storage.sync.get(query, function (result) {
          var error = chrome.runtime && chrome.runtime.lastError;
          if (error) {
            callback(error, defaults || {});
          } else {
            callback(null, result);
          }
        });
      },
      set: function (values, callback) {
        chrome.storage.sync.set(values, function () {
          var error = chrome.runtime && chrome.runtime.lastError;
          if (callback) {
            callback(error || undefined);
          }
        });
      },
      remove: function (keys, callback) {
        chrome.storage.sync.remove(keys, function () {
          var error = chrome.runtime && chrome.runtime.lastError;
          if (callback) {
            callback(error || undefined);
          }
        });
      }
    };
  } else if (hasGMStorage()) {
    platform = {
      get: function (defaults, callback) {
        var result = Object.assign({}, defaults || {});

        try {
          // null/undefined defaults => full read: enumerate every stored key
          // (matches storage.sync.get(null)). With explicit defaults, read only
          // those keys, falling back to the supplied default per key.
          if (defaults == null && typeof GM_listValues === "function") {
            GM_listValues().forEach(function (key) {
              result[key] = parseGMValue(GM_getValue(key, null), undefined);
            });
          } else {
            Object.keys(result).forEach(function (key) {
              result[key] = parseGMValue(GM_getValue(key, null), result[key]);
            });
          }
          callback(null, result);
        } catch (error) {
          callback(error, defaults || {});
        }
      },
      set: function (values, callback) {
        try {
          Object.keys(values).forEach(function (key) {
            if (values[key] === undefined) {
              if (typeof GM_deleteValue === "function") {
                GM_deleteValue(key);
              }
            } else {
              GM_setValue(key, JSON.stringify(values[key]));
            }
          });
          if (callback) {
            callback();
          }
        } catch (error) {
          if (callback) {
            callback(error);
          }
        }
      },
      remove: function (keys, callback) {
        if (!Array.isArray(keys)) {
          keys = [keys];
        }
        try {
          if (typeof GM_deleteValue === "function") {
            keys.forEach(function (key) {
              GM_deleteValue(key);
            });
          }
          if (callback) {
            callback();
          }
        } catch (error) {
          if (callback) {
            callback(error);
          }
        }
      }
    };
  } else {
    platform = PSL.createUnavailableContentSettingsStorage();
  }

  PSL.storagePlatform = platform;

  PSL.getStorage = function (defaults, callback) {
    if (typeof callback !== "function") {
      return platform;
    }
    platform.get(defaults, callback);
  };

  PSL.setStorage = function (values, callback) {
    platform.set(values, callback);
  };

  PSL.removeStorage = function (keys, callback) {
    platform.remove(keys, callback);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/settings-migration.js ----
(function (PSL) {
  PSL.hasStoredSetting = function (storage, key) {
    return Object.prototype.hasOwnProperty.call(storage || {}, key);
  };

  PSL.hasLegacyKeyBindingSettings = function (storage) {
    return [
      "speedStep",
      "resetKeyCode",
      "slowerKeyCode",
      "fasterKeyCode",
      "rewindKeyCode",
      "advanceKeyCode",
      "fastKeyCode"
    ].some(function (key) {
      return PSL.hasStoredSetting(storage, key);
    });
  };

  PSL.buildLegacyKeyBindings = function (storage) {
    return [
      {
        action: "slower",
        key: Number(storage.slowerKeyCode) || 83,
        value: Number(storage.speedStep) || 0.5,
        force: false,
        predefined: true
      },
      {
        action: "faster",
        key: Number(storage.fasterKeyCode) || 68,
        value: Number(storage.speedStep) || 0.5,
        force: false,
        predefined: true
      },
      {
        action: "rewind",
        key: Number(storage.rewindKeyCode) || 90,
        value: Number(storage.rewindTime) || 10,
        force: false,
        predefined: true
      },
      {
        action: "advance",
        key: Number(storage.advanceKeyCode) || 88,
        value: Number(storage.advanceTime) || 10,
        force: false,
        predefined: true
      },
      {
        action: "reset",
        key: Number(storage.resetKeyCode) || 82,
        value: 1.0,
        force: false,
        predefined: true
      },
      {
        action: "fast",
        key: Number(storage.fastKeyCode) || 71,
        value: Number(storage.fastSpeed) || 1.8,
        force: false,
        predefined: true
      }
    ];
  };

  PSL.ensureDefaultBinding = function (settings, action) {
    if (settings.keyBindings.some(function (binding) { return binding.action === action; })) {
      return false;
    }

    var defaultBinding = PSL.defaultKeyBindings.find(function (binding) {
      return binding.action === action;
    });
    if (!defaultBinding) {
      return false;
    }

    settings.keyBindings.push(Object.assign({}, defaultBinding));
    return true;
  };

  PSL.ensureDefaultBindingValue = function (settings, action) {
    var binding = settings.keyBindings.find(function (item) {
      return item.action === action;
    });
    var defaultBinding = PSL.defaultKeyBindings.find(function (item) {
      return item.action === action;
    });
    if (!binding || !defaultBinding) {
      return false;
    }

    var migrated = false;
    if (typeof binding.key === "undefined") {
      binding.key = defaultBinding.key;
      migrated = true;
    }

    if (
      (action === "slower" || action === "faster") &&
      (isNaN(Number(binding.value)) || Number(binding.value) < 0.01)
    ) {
      binding.value = defaultBinding.value;
      migrated = true;
    }

    if (
      (action === "reset" || action === "fast") &&
      (isNaN(Number(binding.value)) || Number(binding.value) <= 0)
    ) {
      binding.value = defaultBinding.value;
      migrated = true;
    }

    return migrated;
  };

  PSL.migrateDefaultSpeedStep = function (settings) {
    var migrated = false;
    settings.keyBindings.forEach(function (binding) {
      if (
        (binding.action === "slower" || binding.action === "faster") &&
        binding.predefined !== false &&
        Number(binding.value) === 0.1
      ) {
        binding.value = 0.5;
        migrated = true;
      }
    });
    return migrated;
  };

  PSL.migrateSpeedStep = function (storage, settings) {
    var speedStep = Number(storage.speedStep);
    if (
      PSL.hasStoredSetting(storage, "speedStep") &&
      !isNaN(speedStep) &&
      speedStep >= 0.01 &&
      speedStep <= 15.93
    ) {
      return { value: speedStep, migrated: false };
    }

    var binding = settings.keyBindings.find(function (item) {
      return item.action === "slower" || item.action === "faster";
    });
    speedStep = binding ? Number(binding.value) : NaN;
    if (!isNaN(speedStep) && speedStep >= 0.01 && speedStep <= 15.93) {
      return { value: speedStep, migrated: true };
    }

    return { value: 0.5, migrated: true };
  };

  PSL.migratePreferredSpeed = function (storage, settings) {
    var preferredSpeed = Number(storage.preferredSpeed);
    var storedBindings = Array.isArray(storage.keyBindings)
      ? storage.keyBindings
      : [];
    var binding;
    if (
      PSL.hasStoredSetting(storage, "preferredSpeed") &&
      !isNaN(preferredSpeed) &&
      preferredSpeed >= 0.07 &&
      preferredSpeed <= 16
    ) {
      return { value: preferredSpeed, migrated: false };
    }

    binding = storedBindings.find(function (item) {
      return item.action === "fast";
    }) || storedBindings.find(function (item) {
      return item.action === "reset";
    }) || settings.keyBindings.find(function (item) {
      return item.action === "fast";
    });
    preferredSpeed = binding ? Number(binding.value) : NaN;
    if (!isNaN(preferredSpeed) && preferredSpeed >= 0.07 && preferredSpeed <= 16) {
      return { value: preferredSpeed, migrated: true };
    }

    return { value: 1.8, migrated: true };
  };

  PSL.migrateReverseFrameRate = function (storage) {
    var reverseFrameRate = Number(storage.reverseFrameRate);
    if (
      PSL.hasStoredSetting(storage, "reverseFrameRate") &&
      !isNaN(reverseFrameRate) &&
      reverseFrameRate >= 0.1 &&
      reverseFrameRate <= 15
    ) {
      return { value: reverseFrameRate, migrated: false };
    }

    return { value: 1, migrated: PSL.hasStoredSetting(storage, "reverseFrameRate") };
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/storage.js ----
(function (PSL) {
  PSL.getKeyBinding = function (action, what) {
    if (!what) {
      what = "value";
    }
    try {
      return PSL.settings.keyBindings.find(function (item) {
        return item.action === action;
      })[what];
    } catch (e) {
      return false;
    }
  };

  PSL.setKeyBinding = function (action, value) {
    var binding = PSL.settings.keyBindings.find(function (item) {
      return item.action === action;
    });
    if (binding) {
      binding.value = value;
    }
  };

  PSL.loadSettings = function (callback) {
    PSL.getStorage(null, function (error, rawStorage) {
      if (error) {
        PSL.log("Failed to read settings from storage; using defaults: " + error, 3);
      }
      rawStorage = rawStorage || {};
      var storage = Object.assign(PSL.cloneDefaultSettings(), rawStorage);
      var settings = PSL.cloneDefaultSettings();
      var migrated = false;
      var speedStep;
      var preferredSpeed;
      var reverseFrameRate;

      if (Array.isArray(rawStorage.keyBindings)) {
        settings.keyBindings = rawStorage.keyBindings.slice();
      } else if (PSL.hasLegacyKeyBindingSettings(rawStorage)) {
        settings.keyBindings = PSL.buildLegacyKeyBindings(rawStorage);
        settings.version = "0.5.3";
        migrated = true;
      }

      ["slower", "faster", "reset", "mark-loop-range", "clear-loop"].forEach(function (action) {
        migrated = PSL.ensureDefaultBinding(settings, action) || migrated;
        migrated = PSL.ensureDefaultBindingValue(settings, action) || migrated;
      });
      migrated = PSL.migrateDefaultSpeedStep(settings) || migrated;

      speedStep = PSL.migrateSpeedStep(rawStorage, settings);
      settings.speedStep = speedStep.value;
      migrated = speedStep.migrated || migrated;

      preferredSpeed = PSL.migratePreferredSpeed(rawStorage, settings);
      settings.preferredSpeed = preferredSpeed.value;
      migrated = preferredSpeed.migrated || migrated;

      reverseFrameRate = PSL.migrateReverseFrameRate(rawStorage, settings);
      settings.reverseFrameRate = reverseFrameRate.value;
      migrated = reverseFrameRate.migrated || migrated;

      settings.keyBindings = settings.keyBindings.filter(function (binding) {
        return PSL.defaultKeyBindings.some(function (defaultBinding) {
          return defaultBinding.action === binding.action;
        });
      });

      settings.lastSpeed = Number(storage.lastSpeed) || 1.0;
      settings.displayKeyCode = Number(storage.displayKeyCode) || 86;
      settings.audioBoolean = Boolean(storage.audioBoolean);
      settings.enabled =
        storage.enabled === undefined
          ? PSL.defaultSettings.enabled
          : Boolean(storage.enabled);
      settings.speedControlsEnabled = storage.speedControlsEnabled !== false;
      settings.loopControlsEnabled = storage.loopControlsEnabled !== false;
      settings.speedScope = PSL.normalizeSpeedScope(storage.speedScope);
      settings.keepSpeedSites = String(storage.keepSpeedSites || "");
      settings.controllerOpacity = Number(storage.controllerOpacity);
      if (isNaN(settings.controllerOpacity)) {
        settings.controllerOpacity = PSL.defaultSettings.controllerOpacity;
      }
      settings.blacklist = String(storage.blacklist);
      settings.logLevel = Number(storage.logLevel) || PSL.defaultSettings.logLevel;
      settings.defaultLogLevel =
        Number(storage.defaultLogLevel) || PSL.defaultSettings.defaultLogLevel;
      settings.speeds = storage.speeds || {};
      settings.domainSpeeds = storage.domainSpeeds || {};

      PSL.settings = settings;
      if (PSL.updateKeepSpeedSiteMatchers) {
        PSL.updateKeepSpeedSiteMatchers();
      }

      if (migrated) {
        PSL.setStorage({
          keyBindings: PSL.settings.keyBindings,
          version: PSL.settings.version,
          displayKeyCode: PSL.settings.displayKeyCode,
          audioBoolean: PSL.settings.audioBoolean,
          speedControlsEnabled: PSL.settings.speedControlsEnabled,
          loopControlsEnabled: PSL.settings.loopControlsEnabled,
          speedStep: PSL.settings.speedStep,
          preferredSpeed: PSL.settings.preferredSpeed,
          reverseFrameRate: PSL.settings.reverseFrameRate,
          keepSpeedSites: PSL.settings.keepSpeedSites,
          enabled: PSL.settings.enabled,
          controllerOpacity: PSL.settings.controllerOpacity,
          blacklist: PSL.settings.blacklist.replace(PSL.regStrip, "")
        }, function (error) {
          if (error) {
            PSL.log("Failed to persist migrated settings: " + error, 3);
          }
        });
      }

      callback();
    });
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/feature-gates.js ----
(function (PSL) {
  var SPEED_ACTIONS = ["slower", "faster", "reset"];
  var LOOP_ACTIONS = ["mark-loop-range", "clear-loop"];

  function numberInRange(value, fallback, min, max) {
    var number = Number(value);
    if (isNaN(number) || number < min || number > max) {
      return fallback;
    }
    return number;
  }

  PSL.isSpeedAction = function (action) {
    return SPEED_ACTIONS.indexOf(action) !== -1;
  };

  PSL.isLoopAction = function (action) {
    return LOOP_ACTIONS.indexOf(action) !== -1;
  };

  PSL.isActionEnabled = function (action) {
    if (PSL.isSpeedAction(action)) {
      return PSL.settings.speedControlsEnabled !== false;
    }
    if (PSL.isLoopAction(action)) {
      return PSL.settings.loopControlsEnabled !== false;
    }
    return false;
  };

  PSL.getSpeedStep = function () {
    return numberInRange(PSL.settings.speedStep, 0.5, 0.01, 15.93);
  };

  PSL.getPreferredSpeed = function () {
    return numberInRange(PSL.settings.preferredSpeed, 1.8, 0.07, 16);
  };

  PSL.getReverseFrameRate = function () {
    return numberInRange(PSL.settings.reverseFrameRate, 1, 0.1, 15);
  };

  PSL.shouldAttachController = function () {
    return (
      PSL.settings.speedControlsEnabled !== false ||
      PSL.settings.loopControlsEnabled !== false
    );
  };

  // Whether a media element should receive an on-video controller. Mirrors the
  // approach proven by other speed extensions: gate on visibility, not on a size
  // threshold. A video that is detached, CSS-invisible
  // (display:none/visibility:hidden/opacity:0), or rendered at zero size gets no
  // controller. This keeps panels off the zero-size transformed <video> layers
  // that sites (e.g. YouTube) keep alongside the real player and that otherwise
  // produce a second, duplicate panel. Audio has no rendered box and stays
  // eligible. Layout APIs are probed defensively so non-browser test
  // environments fall back to offset metrics.
  PSL.isAttachableMediaTarget = function (media) {
    var dkey = PSL.getMediaKey && media ? PSL.getMediaKey(media) : "?";
    if (!media || media.isConnected === false) {
      return false;
    }
    if (media.nodeName === "AUDIO") {
      return true;
    }

    // A <video> with no media source (no currentSrc/src/srcObject) is not a real
    // player. YouTube keeps placeholder/secondary <video> elements beside the real
    // player; they are visible and non-zero-size (so the size gate below does not
    // catch them) but control nothing. Attaching to one produces the observed
    // second, dead panel next to the working player. It becomes attachable again
    // on a later rescan once a source loads, so the real player is not lost.
    if (!(media.currentSrc || media.src || media.srcObject)) {
      return false;
    }

    try {
      if (typeof getComputedStyle === "function") {
        var style = getComputedStyle(media);
        if (
          style &&
          (style.display === "none" ||
            style.visibility === "hidden" ||
            style.opacity === "0")
        ) {
          return false;
        }
      }
    } catch (e) {}

    var width;
    var height;
    var measured = false;
    try {
      if (typeof media.getBoundingClientRect === "function") {
        var rect = media.getBoundingClientRect();
        if (rect) {
          width = rect.width;
          height = rect.height;
          measured = true;
        }
      }
    } catch (e) {}
    if (!measured) {
      width = media.offsetWidth;
      height = media.offsetHeight;
    }

    var ok = Boolean(width) && Boolean(height);
    return ok;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/blacklist.js ----
(function (PSL) {
  PSL.escapeStringRegExp = function (str) {
    var matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g;
    return str.replace(matchOperatorsRe, "\\$&");
  };

  PSL.isBlacklisted = function (url) {
    var targetUrl = typeof url === "string" ? url : location.href;
    var blacklisted = false;
    PSL.settings.blacklist.split("\n").forEach(function (match) {
      var regexp;
      match = match.replace(PSL.regStrip, "");
      if (match.length === 0) {
        return;
      }

      if (match.startsWith("/")) {
        try {
          regexp = new RegExp(match.replace(/^\/|\/$/g, ""));
        } catch (err) {
          return;
        }
      } else {
        regexp = new RegExp(PSL.escapeStringRegExp(match));
      }

      if (regexp.test(targetUrl)) {
        blacklisted = true;
      }
    });
    return blacklisted;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/media-registry.js ----
(function (PSL) {
  PSL.getMediaKey = function (media) {
    if (media.currentSrc || media.src) {
      return media.currentSrc || media.src;
    }
    if (!media._pslMediaId) {
      media._pslMediaId = "media-" + PSL.state.nextMediaId++;
    }
    return media._pslMediaId;
  };

  PSL.registerMedia = function (media) {
    if (PSL.state.mediaElements.indexOf(media) === -1) {
      PSL.state.mediaElements.push(media);
    }
  };

  PSL.unregisterMedia = function (media) {
    var idx = PSL.state.mediaElements.indexOf(media);
    if (idx !== -1) {
      PSL.state.mediaElements.splice(idx, 1);
    }
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/media-element-query.js ----
(function (PSL) {
  PSL.findMediaElements = function (root) {
    var media = [];

    function collect(node) {
      if (!node || !node.querySelectorAll) {
        return;
      }

      node.querySelectorAll("video,audio").forEach(function (element) {
        if (
          element.nodeName === "VIDEO" ||
          (element.nodeName === "AUDIO" && PSL.settings.audioBoolean)
        ) {
          media.push(element);
        }
      });

      node.querySelectorAll("*").forEach(function (element) {
        if (element.shadowRoot) {
          collect(element.shadowRoot);
        }
      });
    }

    collect(root);
    return media;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-hierarchy.js ----
(function (PSL) {
  function isValidSpeed(value) {
    return typeof value === "number" && Number.isFinite(value);
  }

  function clampSpeed(value, min, max) {
    return Math.min(max, Math.max(min, value));
  }

  function resolveCandidate(input, scope, key) {
    if (!input || !isValidSpeed(input[key])) {
      return null;
    }
    return { speed: input[key], scope: scope };
  }

  PSL.getEffectiveSpeedFromHierarchy = function (input) {
    var candidate =
      resolveCandidate(input, "temporary", "temporarySpeed") ||
      resolveCandidate(input, "tab", "tabSpeed") ||
      resolveCandidate(input, "site", "siteSpeed") ||
      resolveCandidate(input, "global", "globalSpeed") ||
      { speed: 1, scope: "global" };
    var min = isValidSpeed(input && input.minSpeed) ? input.minSpeed : -16;
    var max = isValidSpeed(input && input.maxSpeed) ? input.maxSpeed : 16;
    return {
      speed: clampSpeed(candidate.speed, min, max),
      scope: candidate.scope
    };
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-state.js ----
(function (PSL) {
  function getRecords() {
    if (!PSL.state) {
      PSL.state = {};
    }
    if (!PSL.state.mediaSpeedRecords) {
      PSL.state.mediaSpeedRecords = new Map();
    }
    return PSL.state.mediaSpeedRecords;
  }

  function getMediaRecords() {
    if (!PSL.state) {
      PSL.state = {};
    }
    if (!PSL.state.mediaSpeedElementRecords) {
      PSL.state.mediaSpeedElementRecords = new WeakMap();
    }
    return PSL.state.mediaSpeedElementRecords;
  }

  function isFiniteNumber(value) {
    return typeof value === "number" && Number.isFinite(value);
  }

  function normalizeSpeed(speed) {
    // Dead in shipping (clampSpeed always loads), but retained: the test harness
    // loads speed-state in partial bundles without speed-actions, and the
    // fallback is a correct reimplementation. See Plan B P4 (guards kept).
    if (PSL.clampSpeed) {
      return Number(PSL.clampSpeed(speed, -16, 16).toFixed(2));
    }
    return Number(Math.min(Math.max(speed, -16), 16).toFixed(2));
  }

  function getMediaId(video) {
    if (video && PSL.getMediaKey) {
      return PSL.getMediaKey(video);
    }
    // getMediaKey (media-registry.js) is always loaded before this runs; this
    // branch is unreachable in shipped builds. No static shared key: an
    // unidentifiable media falls through to falsy so it is never folded onto a
    // shared record (linkRecordId/ensureRecordForMedia skip falsy ids).
    return video && (video.currentSrc || video.src || video._pslMediaId);
  }

  function getInitialSpeed(video) {
    return isFiniteNumber(video && video.playbackRate) ? video.playbackRate : 1;
  }

  function ensureRecordById(mediaId, initialSpeed, scope) {
    var key = String(mediaId || "");
    var records = getRecords();
    var record = records.get(key);

    if (!record) {
      record = {
        speed: normalizeSpeed(isFiniteNumber(initialSpeed) ? initialSpeed : 1),
        scope: scope || "global"
      };
      records.set(key, record);
    }
    return record;
  }

  function linkRecordId(mediaId, record) {
    var key = String(mediaId || "");
    if (key) {
      getRecords().set(key, record);
    }
  }

  function ensureRecordForMedia(video, initialSpeed, scope) {
    var mediaRecords = getMediaRecords();
    var mediaId = getMediaId(video);
    var record = video ? mediaRecords.get(video) : null;

    if (!record && mediaId) {
      record = getRecords().get(String(mediaId));
    }
    if (!record) {
      record = {
        speed: normalizeSpeed(isFiniteNumber(initialSpeed) ? initialSpeed : 1),
        scope: scope || "global"
      };
    }
    if (video) {
      mediaRecords.set(video, record);
    }
    linkRecordId(mediaId, record);
    return record;
  }

  PSL.setMediaSpeedById = function (mediaId, speed, scope) {
    var record = ensureRecordById(mediaId, speed, scope);
    record.speed = normalizeSpeed(speed);
    record.scope = scope || record.scope || "temporary";
    return record.speed;
  };

  // The page-world authority reads its target speed through this id-keyed path
  // (media-authority.js targetSpeed getter). It MUST resolve the selected scope
  // layer (the single source of truth) exactly like the video-keyed getMediaSpeed
  // — not return the per-media record raw. Returning the raw record let a stale /
  // orphaned record (e.g. 2.5 left behind by a blob-URL swap) override the active
  // tab layer, so the authority kept restoring the old value (the ping-pong bug).
  // A synthetic per-media state (<=0: zero pause / reverse) still wins, mirroring
  // getMediaSpeed.
  PSL.getMediaSpeedById = function (mediaId) {
    var record = ensureRecordById(mediaId, 1, "global");

    if (record && isFiniteNumber(record.speed) && record.speed <= 0) {
      return record.speed;
    }

    if (!PSL.getEffectiveSpeedFromHierarchy) {
      return record.speed;
    }

    var scope = getSelectedScope();
    var layerSpeed = getSelectedLayerSpeed(null, scope, record);

    return PSL.getEffectiveSpeedFromHierarchy({
      temporarySpeed: scope === "video" ? layerSpeed : null,
      tabSpeed: scope === "tab" ? layerSpeed : null,
      siteSpeed: scope === "domain" ? layerSpeed : null,
      globalSpeed: 1,
      minSpeed: -16,
      maxSpeed: 16
    }).speed;
  };

  PSL.getMediaSpeedRecordById = function (mediaId) {
    return ensureRecordById(mediaId, 1, "global");
  };

  PSL.hasMediaSpeed = function (video) {
    return Boolean(
      (video && getMediaRecords().has(video)) ||
      getRecords().has(String(getMediaId(video) || ""))
    );
  };

  PSL.setMediaSpeed = function (video, speed, scope) {
    var record = ensureRecordForMedia(video, speed, scope);
    record.speed = normalizeSpeed(speed);
    record.scope = scope || record.scope || "temporary";
    linkRecordId(getMediaId(video), record);
    return record.speed;
  };

  function findRecord(video) {
    var record = video && getMediaRecords().get(video);
    if (!record) {
      record = getRecords().get(String(getMediaId(video) || ""));
      if (record && video) {
        getMediaRecords().set(video, record);
      }
    }
    if (record) {
      linkRecordId(getMediaId(video), record);
    }
    return record || null;
  }

  // Resolve a value to a supported scope. Prefers PSL.normalizeSpeedScope
  // (defaults.js) but stays self-contained so it never silently falls back to
  // "tab" just because defaults.js has not loaded yet (e.g. partial bundles).
  PSL.resolveSpeedScope = function (scope) {
    if (PSL.normalizeSpeedScope) {
      return PSL.normalizeSpeedScope(scope);
    }
    return scope === "tab" || scope === "domain" || scope === "video"
      ? scope
      : "tab";
  };

  function getSelectedScope() {
    return PSL.resolveSpeedScope(PSL.settings && PSL.settings.speedScope);
  }

  // The single source of truth is the value of the selected scope layer. Returns
  // null when that layer holds no value (which resolves to global=1 downstream).
  // video=per-media record, tab=PSL.state.tabSpeed, domain=saved domain speed.
  function getSelectedLayerSpeed(video, scope, record) {
    if (scope === "tab") {
      return PSL.state && isFiniteNumber(PSL.state.tabSpeed)
        ? PSL.state.tabSpeed
        : null;
    }
    if (scope === "domain") {
      var saved = PSL.getSavedDomainSpeed ? Number(PSL.getSavedDomainSpeed(video)) : NaN;
      return isFiniteNumber(saved) ? saved : null;
    }
    return record && isFiniteNumber(record.speed) ? record.speed : null;
  }

  // Effective speed = resolve the selected layer through the speed hierarchy.
  // No cross-layer fallback: only the selected layer is populated, others null,
  // final fallback global=1. Synthetic per-media states (zero pause / reverse)
  // are actuated on a specific element and bypass resolution in every scope.
  PSL.getMediaSpeed = function (video) {
    var record = findRecord(video);

    if (record && isFiniteNumber(record.speed) && record.speed <= 0) {
      return record.speed;
    }

    // Resolver absent in partial test bundles: fall back to legacy per-media.
    if (!PSL.getEffectiveSpeedFromHierarchy) {
      return record ? record.speed : normalizeSpeed(getInitialSpeed(video));
    }

    var scope = getSelectedScope();
    var layerSpeed = getSelectedLayerSpeed(video, scope, record);

    // video mode, brand-new element with no record: adopt its native rate.
    if (scope === "video" && layerSpeed === null && !record) {
      return normalizeSpeed(getInitialSpeed(video));
    }

    return PSL.getEffectiveSpeedFromHierarchy({
      temporarySpeed: scope === "video" ? layerSpeed : null,
      tabSpeed: scope === "tab" ? layerSpeed : null,
      siteSpeed: scope === "domain" ? layerSpeed : null,
      globalSpeed: 1,
      minSpeed: -16,
      maxSpeed: 16
    }).speed;
  };

  // The maintained value of the selected scope layer (the single source), or
  // null when that layer holds nothing yet. Unlike getMediaSpeed it does NOT
  // fall back to global=1, so callers can tell "maintained speed" from "nothing
  // to maintain" (used by the apply hook to avoid stomping a native rate).
  PSL.getMaintainedSpeed = function (video) {
    var record = findRecord(video);
    // A synthetic per-media state (zero pause / reverse) owns this element and
    // must not be overridden by the selected layer's value — mirror getMediaSpeed.
    if (record && isFiniteNumber(record.speed) && record.speed <= 0) {
      return record.speed;
    }
    return getSelectedLayerSpeed(video, getSelectedScope(), record);
  };

  PSL.getMediaSpeedScope = function (video) {
    var record = video && getMediaRecords().get(video);
    if (!record) {
      record = getRecords().get(String(getMediaId(video) || ""));
    }
    return record ? record.scope : "global";
  };

  PSL.getMediaSpeedMode = function (video) {
    var speed = PSL.getMediaSpeed(video);
    if (speed < 0) {
      return "reverse";
    }
    if (speed === 0) {
      return "zero";
    }
    return "native";
  };

  PSL.clearMediaSpeed = function (video) {
    if (video) {
      getMediaRecords().delete(video);
    }
    getRecords().delete(String(getMediaId(video) || ""));
  };

  PSL.observeNativePlaybackRate = function (video, rate) {
    var speed = Number(rate);
    if (!isFiniteNumber(speed)) {
      return { action: "ignore" };
    }
    if (!PSL.hasMediaSpeed(video)) {
      PSL.setMediaSpeed(video, speed, "observed");
      return { action: "adopt", speed: speed };
    }
    if (Math.abs(PSL.getMediaSpeed(video) - speed) < 0.001) {
      return { action: "confirm", speed: speed };
    }
    return { action: "restore", speed: PSL.getMediaSpeed(video), observedSpeed: speed };
  };

  PSL.getEffectivePlaybackRate = function (video) {
    return PSL.getMediaSpeed(video);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/media-source-lifecycle.js ----
(function (PSL) {
  function getSourceKey(media) {
    if (!media) {
      return "";
    }
    return media.currentSrc || media.src || "";
  }

  function resetSourceScopedState(state) {
    if (!state) {
      return null;
    }
    state.loopStart = 0;
    state.loopEnd = 0;
    state.loopEnabled = false;
    state.syntheticMode = "none";
    return state;
  }

  function resetLoopStateForSourceChange(media, previousSourceKey) {
    var currentSourceKey = getSourceKey(media);

    if (previousSourceKey) {
      PSL.state.startTimes[previousSourceKey] = 0;
      PSL.state.endTimes[previousSourceKey] = 0;
      if (PSL.state.pendingLoopPoints) {
        delete PSL.state.pendingLoopPoints[previousSourceKey];
      }
      PSL.state.loopsEnabled[previousSourceKey] = false;
    }
    if (currentSourceKey) {
      PSL.state.startTimes[currentSourceKey] = 0;
      PSL.state.endTimes[currentSourceKey] = 0;
      if (PSL.state.pendingLoopPoints) {
        delete PSL.state.pendingLoopPoints[currentSourceKey];
      }
      PSL.state.loopsEnabled[currentSourceKey] = false;
    }
    if (media && PSL.disableLoop) {
      PSL.disableLoop(media);
    }
    if (media && media._pslControllerHandle) {
      if (media._pslControllerHandle.clearLoopRangeDisplay) {
        media._pslControllerHandle.clearLoopRangeDisplay();
      }
      if (PSL.updateLoopRangeDisplay) {
        PSL.updateLoopRangeDisplay(media);
      }
    }
  }

  function detectSourceChange(state, nextSourceKey) {
    if (!state || !nextSourceKey) {
      return false;
    }
    if (!state.currentSrc) {
      state.currentSrc = nextSourceKey;
      return false;
    }
    if (state.currentSrc === nextSourceKey) {
      return false;
    }
    state.previousSrc = state.currentSrc;
    state.currentSrc = nextSourceKey;
    resetSourceScopedState(state);
    return true;
  }

  PSL.getMediaSourceKey = getSourceKey;
  PSL.detectMediaSourceChange = detectSourceChange;
  PSL.resetLoopStateForSourceChange = resetLoopStateForSourceChange;
  PSL.resetMediaSourceScopedState = resetSourceScopedState;
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/reverse-playback-engine.js ----
(function (PSL, global) {
  function isValidLoop(loopStart, loopEnd) {
    return (
      typeof loopStart === "number" &&
      typeof loopEnd === "number" &&
      Number.isFinite(loopStart) &&
      Number.isFinite(loopEnd) &&
      loopEnd > loopStart
    );
  }

  function createReversePlaybackEngine(options) {
    var running = new WeakMap();
    var setTimer = options && options.setInterval ? options.setInterval : global.setInterval.bind(global);
    var clearTimer = options && options.clearInterval ? options.clearInterval : global.clearInterval.bind(global);
    var now = options && options.now ? options.now : Date.now;
    var defaultIntervalMs = options && options.intervalMs ? options.intervalMs : 100;

    function stop(video) {
      var state = video && running.get(video);
      if (!state) {
        return false;
      }
      clearTimer(state.timerId);
      running.delete(video);
      return true;
    }

    function tick(video) {
      var state = running.get(video);
      if (!state) {
        return false;
      }
      if (video.isConnected === false || video._pslReversing === false) {
        stop(video);
        if (state.onStop) {
          state.onStop(video);
        }
        return false;
      }
      if (!video.paused && typeof video.pause === "function") {
        video.pause();
      }
      var currentNow = now();
      var elapsedSeconds = Math.max(0, (currentNow - state.lastTick) / 1000);
      state.lastTick = currentNow;
      var stepSeconds =
        typeof state.stepSeconds === "number" && Number.isFinite(state.stepSeconds)
          ? state.stepSeconds
          : Math.abs(state.speed) * elapsedSeconds;
      var nextTime = video.currentTime - stepSeconds;

      if (isValidLoop(state.loopStart, state.loopEnd) && nextTime <= state.loopStart) {
        video.currentTime = state.loopEnd;
        return true;
      }

      if (nextTime <= 0) {
        video.currentTime = 0;
        stop(video);
        if (state.onEnd) {
          state.onEnd(video);
        }
        return true;
      }

      video.currentTime = nextTime;
      return true;
    }

    function start(video, speed, startOptions) {
      if (!video || !(speed < 0)) {
        if (video) {
          stop(video);
        }
        return false;
      }

      stop(video);
      if (typeof video.pause === "function") {
        video.pause();
      }

      var state = {
        speed: speed,
        loopStart: startOptions && startOptions.loopStart,
        loopEnd: startOptions && startOptions.loopEnd,
        onEnd: startOptions && startOptions.onEnd,
        onStop: startOptions && startOptions.onStop,
        stepSeconds: startOptions && startOptions.stepSeconds,
        intervalMs: startOptions && startOptions.intervalMs ? startOptions.intervalMs : defaultIntervalMs,
        lastTick: now(),
        timerId: null
      };
      state.timerId = setTimer(function () {
        tick(video);
      }, state.intervalMs);
      running.set(video, state);
      return true;
    }

    function isRunning(video) {
      return Boolean(video && running.has(video));
    }

    return {
      isRunning: isRunning,
      start: start,
      stop: stop,
      tick: tick
    };
  }

  PSL.createReversePlaybackEngine = createReversePlaybackEngine;
  PSL.reversePlaybackEngine = PSL.reversePlaybackEngine || createReversePlaybackEngine();
})(globalThis.PlaySpeedLoop, globalThis);

// ---- code/extension/content/media-authority.js ----
(function (PSL) {
  function attachSpeedAccessors(state) {
    var fallbackSpeed = 1;
    var fallbackScope = "global";

    Object.defineProperty(state, "targetSpeed", {
      configurable: true,
      enumerable: true,
      get: function () {
        return PSL.getMediaSpeedById
          ? PSL.getMediaSpeedById(state.mediaId)
          : fallbackSpeed;
      },
      set: function (speed) {
        fallbackSpeed = speed;
        if (PSL.setMediaSpeedById) {
          PSL.setMediaSpeedById(state.mediaId, speed, state.speedScope);
        }
      }
    });
    Object.defineProperty(state, "speedScope", {
      configurable: true,
      enumerable: true,
      get: function () {
        return PSL.getMediaSpeedRecordById
          ? PSL.getMediaSpeedRecordById(state.mediaId).scope
          : fallbackScope;
      },
      set: function (scope) {
        fallbackScope = scope || "global";
        if (PSL.getMediaSpeedRecordById) {
          PSL.getMediaSpeedRecordById(state.mediaId).scope = fallbackScope;
        }
      }
    });
    return state;
  }

  function normalizeState(mediaId, currentSrc) {
    // A brand-new media id (e.g. the new blob source after a YouTube navigation)
    // is seeded to 1x/global here as a pure actuator default. The maintained
    // speed is the content runtime's selected scope layer, which re-applies it to
    // the new source via applyEffectiveSpeed — the authority no longer carries
    // speed across the swap itself.
    if (PSL.setMediaSpeedById) {
      PSL.setMediaSpeedById(mediaId, 1, "global");
    }
    return attachSpeedAccessors({
      mediaId: mediaId,
      currentSrc: currentSrc || "",
      previousSrc: "",
      loopStart: 0,
      loopEnd: 0,
      loopEnabled: false,
      syntheticMode: "none"
    });
  }

  function createMediaAuthority(options) {
    var states = new Map();
    var sendCommand = options && typeof options.sendCommand === "function"
      ? options.sendCommand
      : function () {};

    function getScopeRank(scope) {
      if (scope === "temporary") {
        return 3;
      }
      if (scope === "tab") {
        return 2;
      }
      if (scope === "site") {
        return 1;
      }
      return 0;
    }

    function getState(mediaId) {
      var state = states.get(mediaId) || null;
      return state;
    }

    function registerMedia(mediaId, currentSrc) {
      var state = states.get(mediaId);
      if (!state) {
        state = normalizeState(mediaId, currentSrc);
        states.set(mediaId, state);
        return state;
      }
      if (currentSrc) {
        handleSourceChange(mediaId, currentSrc);
      }
      return state;
    }

    function applyTargetSpeed(mediaId, speed, scope, reason) {
      var state = registerMedia(mediaId, "");
      var nextScope = scope || "temporary";
      if (getScopeRank(nextScope) < getScopeRank(state.speedScope)) {
        return false;
      }
      state.speedScope = nextScope;
      if (PSL.setMediaSpeedById) {
        PSL.setMediaSpeedById(mediaId, speed, nextScope);
      } else {
        state.targetSpeed = speed;
      }

      if (speed > 0) {
        state.syntheticMode = "none";
        sendCommand({
          type: "set-playback-rate",
          mediaId: mediaId,
          rate: speed,
          reason: reason || "target-speed"
        });
        return true;
      }

      if (speed === 0) {
        state.syntheticMode = "zero";
        sendCommand({
          type: "set-paused-at-zero",
          mediaId: mediaId,
          enabled: true
        });
        return true;
      }

      state.syntheticMode = "reverse";
      return true;
    }

    function handleObservedPlaybackRate(mediaId, observedRate) {
      var state = getState(mediaId);
      if (!state || state.syntheticMode !== "none" || state.targetSpeed <= 0) {
        return false;
      }
      if (Math.abs(observedRate - state.targetSpeed) < 0.001) {
        return false;
      }
      sendCommand({
        type: "set-playback-rate",
        mediaId: mediaId,
        rate: state.targetSpeed,
        reason: "restore-observed-rate"
      });
      return true;
    }

    function handleSourceChange(mediaId, nextSrc) {
      var state = getState(mediaId);
      if (!state) {
        registerMedia(mediaId, nextSrc);
        return false;
      }
      var changed = PSL.detectMediaSourceChange(state, nextSrc);
      return changed;
    }

    function detachMedia(mediaId) {
      var existed = states.delete(mediaId);
      if (existed) {
        sendCommand({ type: "detach-media", mediaId: mediaId });
      }
      return existed;
    }

    return {
      applyTargetSpeed: applyTargetSpeed,
      detachMedia: detachMedia,
      getState: getState,
      handleObservedPlaybackRate: handleObservedPlaybackRate,
      handleSourceChange: handleSourceChange,
      registerMedia: registerMedia
    };
  }

  PSL.createMediaAuthority = createMediaAuthority;
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/page-world-bridge.js ----
(function (PSL, global) {
  var DISABLED_FLAG = "PSL_V2_PAGE_WORLD_DISABLED";
  var hookPath = "extension/page-world/media-hook.js";
  var mediaById = new Map();

  function isProbeEnabled() {
    var result;
    try {
      result = !global.localStorage || global.localStorage.getItem(DISABLED_FLAG) !== "1";
    } catch (e) {
      result = true;
    }
    return result;
  }

  function getEventTarget() {
    var result = typeof global.addEventListener === "function" ? global : global.window;
    return result;
  }

  function createCustomEvent(type, detail) {
    var EventCtor = global.CustomEvent || (global.window && global.window.CustomEvent);
    var encodedDetail = encodeBridgePayload(detail);
    if (typeof EventCtor === "function") {
      return new EventCtor(type, { detail: encodedDetail });
    }
    return { type: type, detail: encodedDetail };
  }

  function encodeBridgePayload(detail) {
    return JSON.stringify(detail || {});
  }

  function decodeBridgePayload(detail) {
    if (typeof detail === "string") {
      try {
        return JSON.parse(detail);
      } catch (e) {
        return null;
      }
    }
    return detail || null;
  }

  function getMediaId(media) {
    var mediaId = PSL.getMediaKey ? PSL.getMediaKey(media) : media.currentSrc || media.src;
    return mediaId;
  }

  function appendScript(doc, channelId, src) {
    var script = doc.createElement("script");
    script.src = src;
    script.async = false;
    script.dataset.pslV2ProbeChannel = channelId;
    script.onload = function () {
      if (script.parentNode) {
        script.parentNode.removeChild(script);
      }
    };
    (doc.documentElement || doc.head || doc.body).appendChild(script);
  }

  function installProbe(doc) {
    if (!doc || doc.__pslV2PageWorldProbeInstalled) {
      return false;
    }

    var src = PSL.getAssetUrl(hookPath);
    if (!src) {
      return false;
    }

    var channelId = "psl-v2-probe-" + PSL.instanceId;
    var fromPage = channelId + ":from-page";
    var toPage = channelId + ":to-page";
    var eventTarget = getEventTarget();

    function sendCommand(command) {
      if (eventTarget && typeof eventTarget.dispatchEvent === "function") {
        eventTarget.dispatchEvent(createCustomEvent(toPage, command));
      }
    }

    PSL.v2MediaAuthority = PSL.v2MediaAuthority || PSL.createMediaAuthority({
      sendCommand: sendCommand
    });

    doc.__pslV2PageWorldProbeInstalled = true;
    if (eventTarget && typeof eventTarget.addEventListener === "function") {
      eventTarget.addEventListener(fromPage, function (event) {
        var detail = decodeBridgePayload(event.detail);
        PSL.pageWorldBridge.handlePageEvent(detail);
      });
    }

    appendScript(doc, channelId, src);
    return true;
  }

  function handlePageEvent(detail) {
    var authority = PSL.v2MediaAuthority;
    if (!authority || !detail || !detail.mediaId) {
      return;
    }
    if (
      detail.type === "media-seen" ||
      detail.type === "loadedmetadata" ||
      detail.type === "play" ||
      detail.type === "pause" ||
      detail.type === "load"
    ) {
      authority.registerMedia(detail.mediaId, detail.currentSrc || "");
      return;
    }
    if (detail.type === "sourcechange") {
      // Register the new blob source with the authority. The authority no longer
      // carries speed across the swap — the single source of truth is the
      // selected scope layer (re-applied by the content runtime, not here).
      authority.handleSourceChange(detail.mediaId, detail.currentSrc || "");
      resetLoopForPageSourceChange(detail);
      return;
    }
    if (
      detail.type === "ratechange" ||
      detail.type === "playbackRate-set" ||
      detail.type === "defaultPlaybackRate-set"
    ) {
      authority.registerMedia(detail.mediaId, detail.currentSrc || "");
      if (detail.commandedPlaybackRate === true) {
        return;
      }
      authority.handleObservedPlaybackRate(detail.mediaId, Number(detail.playbackRate));
    }
  }

  function applySpeed(video, speed, scope, reason) {
    var mediaId;
    if (!PSL.v2MediaAuthority) {
      return false;
    }
    mediaId = getMediaId(video);
    mediaById.set(mediaId, video);
    PSL.v2MediaAuthority.registerMedia(mediaId, PSL.getMediaSourceKey(video));
    PSL.v2MediaAuthority.applyTargetSpeed(mediaId, speed, scope, reason);
    return true;
  }

  function resetLoopForPageSourceChange(detail) {
    var media = mediaById.get(detail.mediaId) || mediaById.get(detail.previousSrc);
    if (media && PSL.resetLoopStateForSourceChange) {
      PSL.resetLoopStateForSourceChange(media, detail.previousSrc);
    }
    if (media && detail.currentSrc) {
      mediaById.set(detail.currentSrc, media);
    }
  }

  PSL.pageWorldBridge = {
    applySpeed: applySpeed,
    handlePageEvent: handlePageEvent,
    installProbe: installProbe,
    isProbeEnabled: isProbeEnabled
  };

  if (isProbeEnabled()) {
    installProbe(document);
  }
})(globalThis.PlaySpeedLoop, globalThis);

// ---- code/extension/content/site-placement.js ----
(function (PSL) {
  PSL.isAmazonVideoDocument = function (doc, host) {
    host = host || (doc.location ? doc.location.hostname : location.hostname);
    return (
      /(^|\.)amazon\./.test(host) ||
      /(^|\.)primevideo\.com$/.test(host) ||
      Boolean(doc.getElementById("dv-web-player"))
    );
  };

  function findAmazonVideoContainer(doc) {
    var selectors = [
      "#dv-web-player",
      ".dv-player-fullscreen",
      ".atvwebplayersdk-player-container",
      ".atvwebplayersdk-overlay-container",
      ".atvwebplayersdk-overlays-container",
      ".atvwebplayersdk-persistent-component-container",
      ".webPlayerSDKContainer"
    ];

    for (var i = 0; i < selectors.length; i++) {
      var container = doc.querySelector(selectors[i]);
      if (container) {
        return container;
      }
    }

    return doc.body;
  }

  PSL.placeController = function (videoController, fragment) {
    var doc = videoController.video.ownerDocument;
    var host = doc.location ? doc.location.hostname : location.hostname;
    var parent = videoController.parent;
    var wrapper = videoController.wrapper;

    switch (true) {
      case PSL.isAmazonVideoDocument(doc, host):
        wrapper.style.position = "fixed";
        wrapper.style.zIndex = "2147483647";
        wrapper.style.top = "16px";
        wrapper.style.left = "16px";
        wrapper.style.width = "280px";
        wrapper.style.height = "90px";
        wrapper.style.pointerEvents = "auto";
        findAmazonVideoContainer(doc).appendChild(fragment);
        break;
      default:
        parent.insertBefore(fragment, parent.firstChild);
    }
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/overlay-critical-css.js ----
(function (PSL) {
  PSL.getOverlayCriticalCss = function () {
    return (
      "#controller.panel-collapsed:not(.expanded) #speed-row," +
      "#controller.panel-collapsed:not(.expanded) #loop-row," +
      "#controller.panel-collapsed:not(.expanded) .collapse-control{" +
      "display:none !important;" +
      "}" +
      "#controller.expanded #collapsed-summary," +
      "#controller.expanded .expand-control{" +
      "display:none !important;" +
      "}"
    );
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/overlay-template.js ----
(function (PSL) {
  function appendTextElement(doc, parent, tagName, text, className, id) {
    var element = doc.createElement(tagName);
    if (id) {
      element.id = id;
    }
    if (className) {
      element.className = className;
    }
    element.textContent = text;
    parent.appendChild(element);
    return element;
  }

  function appendButton(doc, parent, action, text, className, id) {
    var button = appendTextElement(doc, parent, "button", text, className, id);
    if (action) {
      button.dataset.action = action;
    }
    return button;
  }

  PSL.buildOverlayStyleElement = function (doc) {
    var style = doc.createElement("style");
    var overlayStyleUrl = PSL.getAssetUrl("extension/styles/overlay-shadow.css");
    style.textContent =
      (overlayStyleUrl ? '@import "' + overlayStyleUrl + '";' : "") +
      PSL.getOverlayCriticalCss();
    return style;
  };

  PSL.buildOverlayControllerContent = function (doc, options) {
    var fragment = doc.createDocumentFragment();
    var controller = doc.createElement("div");
    var controllerClasses = [];

    if (options.speedControlsEnabled) {
      controllerClasses.push("speed-enabled");
    }
    if (options.loopControlsEnabled) {
      controllerClasses.push("loop-enabled");
    }

    controller.id = "controller";
    controllerClasses.push("panel-collapsed");
    controller.className = controllerClasses.join(" ");
    controller.style.top = options.top;
    controller.style.left = options.left;
    if (controller.style.setProperty) {
      controller.style.setProperty("--psl-panel-background-opacity", String(options.opacity));
    } else {
      controller.style["--psl-panel-background-opacity"] = String(options.opacity);
    }

    if (options.speedControlsEnabled) {
      var speedRow = doc.createElement("div");
      speedRow.id = "speed-row";
      speedRow.className = "control-row";
      appendTextElement(doc, speedRow, "span", "Speed", "row-label");
      appendButton(doc, speedRow, "slower", "-");
      var speedExpandedSlot = doc.createElement("span");
      speedExpandedSlot.dataset.pslSlot = "speed-expanded";
      speedRow.appendChild(speedExpandedSlot);
      appendButton(doc, speedRow, "faster", "+");
      appendButton(doc, speedRow, "reset", "Reset", "reset-button");
      controller.appendChild(speedRow);
    }

    if (options.loopControlsEnabled || options.speedControlsEnabled) {
      var collapsedSummary = doc.createElement("div");
      collapsedSummary.id = "collapsed-summary";
      collapsedSummary.className = "body";
      var speedCompactSlot = doc.createElement("span");
      speedCompactSlot.dataset.pslSlot = "speed-compact";
      collapsedSummary.appendChild(speedCompactSlot);
      if (options.loopControlsEnabled) {
        var loopSummarySlot = doc.createElement("span");
        loopSummarySlot.dataset.pslSlot = "loop-summary";
        loopSummarySlot.className = "loop-summary";
        collapsedSummary.appendChild(loopSummarySlot);
      }
      controller.appendChild(collapsedSummary);
    }

    if (options.loopControlsEnabled) {
      var loopRow = doc.createElement("div");
      loopRow.id = "loop-row";
      loopRow.className = "control-row";
      appendTextElement(doc, loopRow, "span", "Loop", "row-label");
      appendTextElement(doc, loopRow, "span", "--:--", "time-value empty", "start-indicator");
      appendTextElement(doc, loopRow, "span", "--:--", "time-value empty", "end-indicator");
      appendButton(doc, loopRow, "clear-loop", "Reset", "reset-button");
      controller.appendChild(loopRow);
    }

    appendButton(
      doc,
      controller,
      null,
      "",
      "panel-toggle chevron collapse-control",
      "collapse-button"
    ).setAttribute("aria-label", "Collapse controller");
    appendButton(
      doc,
      controller,
      null,
      "",
      "panel-toggle toggle expand-control",
      "expand-button"
    ).setAttribute("aria-label", "Expand controller");

    fragment.appendChild(PSL.buildOverlayStyleElement(doc));
    fragment.appendChild(controller);
    return fragment;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-indicator-view.js ----
(function (PSL) {
  PSL.formatSpeedDisplayValue = function (speed) {
    return speed.toFixed(2);
  };

  PSL.createSpeedView = function (options) {
    var video = options.video;
    var doc = options.doc;
    var compactSlot = options.compactSlot || options.mountSlot;
    var expandedSlot = options.expandedSlot || compactSlot;
    var root = doc.createElement("span");

    root.id = "speed-indicator";
    root.className = "speed-value";
    root.dataset.pslOwner = "speed-view";

    function moveTo(slot) {
      if (slot && root.parentNode !== slot) {
        slot.appendChild(root);
      }
    }

    function render() {
      var speed = PSL.getMediaSpeed ? PSL.getMediaSpeed(video) : video.playbackRate;
      root.textContent = PSL.formatSpeedDisplayValue(speed);
    }

    return {
      mount: function () {
        moveTo(compactSlot);
      },
      render: render,
      showCompact: function () {
        moveTo(compactSlot);
      },
      showExpanded: function () {
        moveTo(expandedSlot);
      },
      destroy: function () {
        root.remove();
      }
    };
  };

  PSL.renderSpeedView = function (video) {
    if (!video._pslControllerHandle || !video._pslControllerHandle.renderSpeedView) {
      return;
    }
    video._pslControllerHandle.renderSpeedView();
  };

  PSL.updateSpeedIndicator = function (video) {
    PSL.renderSpeedView(video);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/loop-actions.js ----
(function (PSL) {
  function getLoopKey(video) {
    return PSL.getMediaKey(video);
  }

  function hasNumber(value) {
    return typeof value === "number" && !isNaN(value);
  }

  function getEffectiveRate(video) {
    if (PSL.getEffectivePlaybackRate) {
      return PSL.getEffectivePlaybackRate(video);
    }
    return video.playbackRate;
  }

  function normalizeEndpoint(video, time) {
    var point = Number(time);
    if (isNaN(point) || point < 0) {
      return NaN;
    }
    if (video.duration && point >= video.duration) {
      return video.duration - 0.05;
    }
    return point;
  }

  function formatSummary(left, right, pending, enabled) {
    if (hasNumber(pending)) {
      return {
        text: PSL.convertSecToMin(pending) + " / __:__",
        left: PSL.convertSecToMin(pending),
        right: "__:__",
        rightBlank: true
      };
    }

    if (enabled && hasNumber(left) && hasNumber(right) && right > left) {
      return {
        text: PSL.convertSecToMin(left) + " / " + PSL.convertSecToMin(right),
        left: PSL.convertSecToMin(left),
        right: PSL.convertSecToMin(right),
        rightBlank: false
      };
    }

    return {
      text: "",
      left: "",
      right: "",
      rightBlank: false
    };
  }

  PSL.convertSecToMin = function (timeInSecs) {
    var tempDate = new Date(null);
    tempDate.setSeconds(Math.round(timeInSecs));
    return timeInSecs >= 3600
      ? tempDate.toISOString().substring(11, 19)
      : tempDate.toISOString().substring(14, 19);
  };

  PSL.getLoopSummary = function (video) {
    var src = getLoopKey(video);
    var left = PSL.state.startTimes[src];
    var right = PSL.state.endTimes[src];
    var pending = PSL.state.pendingLoopPoints[src];
    return formatSummary(left, right, pending, PSL.state.loopsEnabled[src]);
  };

  PSL.renderLoopSummary = function (video) {
    var summary = PSL.getLoopSummary(video);
    return summary.text;
  };

  PSL.updateCollapsedLoopSummary = function (video) {
    return PSL.renderLoopSummary(video);
  };

  PSL.updateLoopRangeDisplay = function (video) {
    var handle = video._pslControllerHandle;
    if (!handle || !handle.renderLoopRange) {
      return;
    }
    handle.renderLoopRange();
  };

  PSL.markLoopRange = function (video, time) {
    var src = getLoopKey(video);
    var point = normalizeEndpoint(video, time);
    var pending = PSL.state.pendingLoopPoints[src];
    var left;
    var right;

    if (isNaN(point)) {
      return;
    }

    if (!hasNumber(pending) || PSL.state.loopsEnabled[src]) {
      PSL.disableLoop(video);
      PSL.state.pendingLoopPoints[src] = point;
      PSL.state.startTimes[src] = point;
      PSL.state.endTimes[src] = 0;
      PSL.updateLoopRangeDisplay(video);
      return;
    }

    left = Math.min(pending, point);
    right = Math.max(pending, point);
    if (right <= left) {
      PSL.updateLoopRangeDisplay(video);
      return;
    }

    PSL.state.startTimes[src] = left;
    PSL.state.endTimes[src] = right;
    delete PSL.state.pendingLoopPoints[src];
    PSL.enableLoop(video);
    PSL.updateLoopRangeDisplay(video);
  };

  PSL.setLoopRange = function (video, left, right) {
    var src = getLoopKey(video);
    var normalizedLeft = normalizeEndpoint(video, Math.min(left, right));
    var normalizedRight = normalizeEndpoint(video, Math.max(left, right));

    if (isNaN(normalizedLeft) || isNaN(normalizedRight) || normalizedRight <= normalizedLeft) {
      PSL.clearLoop(video);
      return;
    }

    PSL.state.startTimes[src] = normalizedLeft;
    PSL.state.endTimes[src] = normalizedRight;
    delete PSL.state.pendingLoopPoints[src];
    PSL.enableLoop(video);
    PSL.updateLoopRangeDisplay(video);
  };

  PSL.enableLoop = function (video) {
    var src = getLoopKey(video);
    var left = PSL.state.startTimes[src];
    var right = PSL.state.endTimes[src];

    if (!hasNumber(left) || !hasNumber(right) || right <= left) {
      PSL.state.loopsEnabled[src] = false;
      PSL.updateLoopRangeDisplay(video);
      return;
    }

    PSL.state.loopsEnabled[src] = true;
    var handle = video._pslControllerHandle;

    if (!handle || !handle.setLoopHandler) {
      PSL.updateLoopRangeDisplay(video);
      return;
    }

    handle.setLoopHandler(function () {
      var currentSrc = getLoopKey(video);
      var currentLeft = PSL.state.startTimes[currentSrc];
      var currentRight = PSL.state.endTimes[currentSrc];
      var rate = getEffectiveRate(video);

      if (!PSL.state.loopsEnabled[currentSrc]) {
        if (video._pslControllerHandle && video._pslControllerHandle.clearLoopHandler) {
          video._pslControllerHandle.clearLoopHandler();
        }
        return;
      }

      if (rate < 0) {
        if (video.currentTime <= currentLeft) {
          video.currentTime = currentRight;
        }
        return;
      }

      if (video.currentTime >= currentRight || video.currentTime < currentLeft) {
        video.currentTime = currentLeft;
      }
    });
    PSL.updateLoopRangeDisplay(video);
  };

  PSL.disableLoop = function (video) {
    var src = getLoopKey(video);
    var handle = video._pslControllerHandle;
    PSL.state.loopsEnabled[src] = false;
    if (handle && handle.clearLoopHandler) {
      handle.clearLoopHandler();
    }
    PSL.updateLoopRangeDisplay(video);
  };

  PSL.clearLoop = function (video) {
    var src = getLoopKey(video);
    PSL.disableLoop(video);
    PSL.state.startTimes[src] = 0;
    PSL.state.endTimes[src] = 0;
    delete PSL.state.pendingLoopPoints[src];
    PSL.updateLoopRangeDisplay(video);
  };

  PSL.updateLoopToggleDisplay = function (video) {
    PSL.updateLoopRangeDisplay(video);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/loop-view.js ----
(function (PSL) {
  function hasNumber(value) {
    return typeof value === "number" && !isNaN(value);
  }

  function setIndicator(indicator, text, empty) {
    if (!indicator) {
      return;
    }
    indicator.textContent = text;
    if (indicator.classList) {
      indicator.classList.toggle("empty", Boolean(empty));
    }
  }

  function appendSummaryPart(doc, parent, text, className) {
    var span = doc.createElement("span");
    span.className = className;
    span.textContent = text;
    parent.appendChild(span);
  }

  PSL.createLoopView = function (options) {
    var video = options.video;
    var startSlot = options.startSlot;
    var endSlot = options.endSlot;
    var summarySlot = options.summarySlot;

    function renderSummary() {
      var summary = PSL.getLoopSummary ? PSL.getLoopSummary(video) : { text: "" };
      var doc;

      if (!summarySlot) {
        return summary.text;
      }

      if (!summarySlot.ownerDocument || !summarySlot.replaceChildren) {
        summarySlot.textContent = summary.text;
        return summary.text;
      }

      doc = summarySlot.ownerDocument;
      summarySlot.replaceChildren();
      if (summary.left || summary.right) {
        appendSummaryPart(doc, summarySlot, summary.left, "summary-time");
        appendSummaryPart(doc, summarySlot, " / ", "summary-separator");
        appendSummaryPart(
          doc,
          summarySlot,
          summary.right,
          summary.rightBlank ? "summary-time blank" : "summary-time"
        );
      }
      return summary.text;
    }

    function clearRange() {
      setIndicator(startSlot, "--:--", true);
      setIndicator(endSlot, "--:--", true);
      if (summarySlot) {
        if (summarySlot.replaceChildren) {
          summarySlot.replaceChildren();
        } else {
          summarySlot.textContent = "";
        }
      }
    }

    return {
      renderRange: function () {
        var src = PSL.getMediaKey(video);
        var left = PSL.state.startTimes[src];
        var right = PSL.state.endTimes[src];
        var pending = PSL.state.pendingLoopPoints[src];

        if (hasNumber(pending)) {
          setIndicator(startSlot, PSL.convertSecToMin(pending), false);
          setIndicator(endSlot, "--:--", true);
        } else if (PSL.state.loopsEnabled[src] && hasNumber(left) && hasNumber(right) && right > left) {
          setIndicator(startSlot, PSL.convertSecToMin(left), false);
          setIndicator(endSlot, PSL.convertSecToMin(right), false);
        } else {
          setIndicator(startSlot, "--:--", true);
          setIndicator(endSlot, "--:--", true);
        }

        renderSummary();
      },
      clearRange: clearRange,
      renderSummary: renderSummary,
      destroy: clearRange
    };
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/media-overlay-controller.js ----
(function (PSL) {
  function hasMediaSource(media) {
    return Boolean(media.currentSrc || media.src || media.srcObject);
  }

  function createControllerHandle(controller) {
    return {
      isCurrentInstance: function (instanceId) {
        return controller._pslInstanceId === instanceId;
      },
      getParent: function () {
        return controller.parent;
      },
      matchesControllerElement: function (element) {
        return Boolean(element && controller.div === element);
      },
      remove: function () {
        controller.remove();
      },
      setPanelExpanded: function (expanded) {
        controller.setPanelExpanded(expanded);
      },
      renderSpeedView: function () {
        controller.renderSpeedView();
      },
      renderLoopRange: function () {
        controller.renderLoopRange();
      },
      clearLoopRangeDisplay: function () {
        controller.clearLoopRangeDisplay();
      },
      setLoopHandler: function (handler) {
        controller.setLoopHandler(handler);
      },
      clearLoopHandler: function () {
        controller.clearLoopHandler();
      },
      showController: function () {
        controller.showController();
      },
      blinkController: function () {
        controller.blinkController();
      },
      updateSourceVisibility: function () {
        controller.updateSourceVisibility();
      },
      maintainSpeed: function (trigger) {
        controller.maintainSpeed(trigger || "handle");
      }
    };
  }

  PSL.VideoController = function (target, parent) {
    var controller = this;

    if (target._pslControllerHandle) {
      return target._pslControllerHandle;
    }

    PSL.registerMedia(target);

    this._pslInstanceId = PSL.instanceId;
    this.video = target;
    this.parent = target.parentElement || parent;
    this._loopHandler = null;
    this.updateSourceVisibility = function () {
      if (!controller.div) {
        return;
      }
      controller.div.classList.toggle("mpc-nosource", !hasMediaSource(target));
    };
    // On attach and every source swap, apply the maintained speed (the selected
    // scope layer: tab / domain / video) to the new source exactly once. This is
    // the single source of truth — no fallback chain. New videos resolve the
    // selected layer; reverse carries too (see speed-apply.js).
    this.maintainSpeed = function (trigger) {
      if (PSL.applyEffectiveSpeed) {
        PSL.applyEffectiveSpeed(target);
      }
    };

    PSL.state.startTimes[PSL.getMediaKey(target)] = 0;
    target.addEventListener(
      "loadedmetadata",
      (this.handleLoadedMetadata = function () {
        controller.updateSourceVisibility();
        controller.maintainSpeed("loadedmetadata");
      })
    );
    PSL.state.loopsEnabled[PSL.getMediaKey(target)] = false;

    this.div = this.initializeControls();
    target._pslControllerHandle = createControllerHandle(this);
    this.maintainSpeed("attach");
    if (PSL.updateLoopRangeDisplay) {
      PSL.updateLoopRangeDisplay(this.video);
    }

    this.handleSourceVisibilityUpdate = function (event) {
      controller.updateSourceVisibility();
      controller.maintainSpeed((event && event.type) || "sourceVisibility");
    };
    target.addEventListener("loadstart", this.handleSourceVisibilityUpdate);
    target.addEventListener("loadeddata", this.handleSourceVisibilityUpdate);
    target.addEventListener("canplay", this.handleSourceVisibilityUpdate);
    target.addEventListener("emptied", this.handleSourceVisibilityUpdate);

    this.sourceObserver = new MutationObserver(
      function (mutations) {
        mutations.forEach(function (mutation) {
          if (
            mutation.type === "attributes" &&
            (mutation.attributeName === "src" || mutation.attributeName === "currentSrc")
          ) {
            PSL.log("mutation of A/V element", 5);
            if (target._pslControllerHandle) {
              controller.updateSourceVisibility();
              controller.maintainSpeed("srcMutation:" + mutation.attributeName);
            }
          }
        });
      }
    );
    this.sourceObserver.observe(target, {
      attributeFilter: ["src", "currentSrc"]
    });
  };

  PSL.VideoController.prototype.remove = function () {
    if (this.destroySpeedView) {
      this.destroySpeedView();
    }
    this.div.remove();
    this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
    this.video.removeEventListener("loadstart", this.handleSourceVisibilityUpdate);
    this.video.removeEventListener("loadeddata", this.handleSourceVisibilityUpdate);
    this.video.removeEventListener("canplay", this.handleSourceVisibilityUpdate);
    this.video.removeEventListener("emptied", this.handleSourceVisibilityUpdate);
    if (this._loopHandler) {
      this.video.removeEventListener("timeupdate", this._loopHandler);
    }
    if (this.sourceObserver) {
      this.sourceObserver.disconnect();
    }
    if (PSL.stopSyntheticSpeed) {
      PSL.stopSyntheticSpeed(this.video);
    }
    delete this.video._pslControllerHandle;
    PSL.unregisterMedia(this.video);
  };

  PSL.VideoController.prototype.initializeControls = function () {
    PSL.log("initializeControls Begin", 5);
    var doc = this.video.ownerDocument;
    var top = Math.max(this.video.offsetTop, 0) + "px";
    var left = Math.max(this.video.offsetLeft, 0) + "px";
    var wrapper = doc.createElement("div");

    wrapper.classList.add("mpc-controller");
    wrapper._pslInstanceId = PSL.instanceId;
    wrapper._pslMediaElement = this.video;
    // Page-world-readable mirror of the bound media/instance (the expandos above
    // are invisible across the content/page boundary). Lets a page console probe
    // tell whether two .mpc-controller wrappers share a media element and a
    // content instance when diagnosing duplicate panels.
    try {
      wrapper.dataset.pslMedia = String(PSL.getMediaKey ? PSL.getMediaKey(this.video) : "");
      wrapper.dataset.pslInstance = String(PSL.instanceId);
      wrapper.dataset.pslFrame = window.self === window.top ? "top" : "iframe";
    } catch (e) {}

    if (!hasMediaSource(this.video)) {
      wrapper.classList.add("mpc-nosource");
    }
    var shadow = wrapper.attachShadow({ mode: "closed" });
    shadow.appendChild(PSL.buildOverlayControllerContent(doc, {
      top: top,
      left: left,
      opacity: PSL.settings.controllerOpacity,
      speedControlsEnabled: PSL.settings.speedControlsEnabled !== false,
      loopControlsEnabled: PSL.settings.loopControlsEnabled !== false
    }));

    wrapper._shadow = shadow;
    shadow.querySelectorAll("button").forEach(function (button) {
      button.addEventListener(
        "click",
        function (e) {
          var eventTarget = e.target || button;
          var action = eventTarget.dataset.action;
          if (!action) {
            return;
          }
          PSL.runAction(action, PSL.getKeyBinding(action), e);
          e.stopPropagation();
        },
        true
      );
    });

    var controllerElement = shadow.querySelector("#controller");
    var collapseButton = shadow.querySelector("#collapse-button");
    var expandButton = shadow.querySelector("#expand-button");
    var speedExpandedSlot = shadow.querySelector('[data-psl-slot="speed-expanded"]');
    var speedCompactSlot = shadow.querySelector('[data-psl-slot="speed-compact"]');
    var loopSummarySlot = shadow.querySelector('[data-psl-slot="loop-summary"]');
    var startSlot = shadow.querySelector("#start-indicator");
    var endSlot = shadow.querySelector("#end-indicator");
    var speedView = null;
    var loopView = null;
    var setPanelExpanded = function (expanded) {
      controllerElement.classList.toggle("expanded", Boolean(expanded));
      controllerElement.classList.toggle("panel-collapsed", !expanded);
      if (!speedView) {
        return;
      }
      if (expanded) {
        speedView.showExpanded();
      } else {
        speedView.showCompact();
      }
    };

    if (PSL.createSpeedView && speedCompactSlot) {
      speedView = PSL.createSpeedView({
        video: this.video,
        doc: doc,
        compactSlot: speedCompactSlot,
        expandedSlot: speedExpandedSlot
      });
      speedView.mount();
      speedView.render();
    }

    if (PSL.createLoopView) {
      loopView = PSL.createLoopView({
        video: this.video,
        startSlot: startSlot,
        endSlot: endSlot,
        summarySlot: loopSummarySlot
      });
    }

    this.renderSpeedView = function () {
      if (speedView) {
        speedView.render();
      }
    };
    this.destroySpeedView = function () {
      if (speedView) {
        speedView.destroy();
        speedView = null;
      }
    };
    this.renderLoopRange = function () {
      if (loopView) {
        loopView.renderRange();
      }
    };
    this.clearLoopRangeDisplay = function () {
      if (loopView) {
        loopView.clearRange();
      }
    };
    this.setLoopHandler = function (handler) {
      this.clearLoopHandler();
      if (handler) {
        this._loopHandler = handler;
        this.video.addEventListener("timeupdate", handler);
      }
    };
    this.clearLoopHandler = function () {
      if (this._loopHandler) {
        this.video.removeEventListener("timeupdate", this._loopHandler);
        this._loopHandler = null;
      }
    };
    this.showController = function () {
      wrapper.classList.add("mpc-show");
      wrapper.classList.remove("mpc-hidden");
    };
    this.blinkController = function () {
      wrapper.classList.add("mpc-show");
      wrapper.classList.remove("mpc-hidden");
    };

    if (collapseButton) {
      collapseButton.addEventListener("click", function (e) {
        setPanelExpanded(false);
        e.stopPropagation();
      }, true);
    }
    if (expandButton) {
      expandButton.addEventListener("click", function (e) {
        setPanelExpanded(true);
        e.stopPropagation();
      }, true);
    }

    controllerElement.addEventListener("mouseenter", function (e) { return e.stopPropagation(); }, false);
    controllerElement.addEventListener("mouseleave", function (e) { return e.stopPropagation(); }, false);
    controllerElement.addEventListener("click", function (e) { return e.stopPropagation(); }, false);
    controllerElement.addEventListener("mousedown", function (e) { return e.stopPropagation(); }, false);

    this.setPanelExpanded = setPanelExpanded;

    var fragment = doc.createDocumentFragment();
    fragment.appendChild(wrapper);
    this.div = wrapper;
    PSL.placeController({
      video: this.video,
      parent: this.parent,
      wrapper: wrapper
    }, fragment);
    return wrapper;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/controller-attachment-lifecycle.js ----
(function (PSL) {
  function isCurrentController(media) {
    try {
      return (
        media._pslControllerHandle &&
        media._pslControllerHandle.isCurrentInstance &&
        media._pslControllerHandle.isCurrentInstance(PSL.instanceId)
      );
    } catch (e) {
      return false;
    }
  }

  function isControllerAttachedToCurrentParent(media) {
    try {
      return (
        media._pslControllerHandle &&
        media._pslControllerHandle.getParent &&
        media._pslControllerHandle.getParent() === media.parentElement
      );
    } catch (e) {
      return false;
    }
  }

  function removeOrphanControllerElements(media) {
    var doc = media && media.ownerDocument;
    if (!doc || !doc.querySelectorAll) {
      return;
    }

    doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
      if (
        controller._pslInstanceId === PSL.instanceId &&
        controller._pslMediaElement === media &&
        (!media._pslControllerHandle ||
          !media._pslControllerHandle.matchesControllerElement ||
          !media._pslControllerHandle.matchesControllerElement(controller))
      ) {
        controller.remove();
      }
    });
  }

  PSL.removeControllerFromMedia = function (media) {
    if (!media._pslControllerHandle) {
      removeOrphanControllerElements(media);
      return;
    }

    try {
      if (typeof media._pslControllerHandle.remove === "function") {
        media._pslControllerHandle.remove();
        return;
      }
    } catch (e) {
      // Fall through and remove the visible controller element directly.
    }

    delete media._pslControllerHandle;
    PSL.unregisterMedia(media);
    removeOrphanControllerElements(media);
  };

  PSL.attachControllerToMedia = function (media) {
    var mediaDoc = media && media.ownerDocument;
    removeOrphanControllerElements(media);

    if (!PSL.shouldAttachController()) {
      return;
    }

    // Do not attach to invisible/zero-size media (e.g. a site's zero-size
    // transformed <video> layer). Existing controllers are left untouched; this
    // only suppresses new attachment. Guarded so partial bundles without
    // feature-gates keep working.
    if (PSL.isAttachableMediaTarget && !PSL.isAttachableMediaTarget(media)) {
      return;
    }

    if (isCurrentController(media)) {
      if (!isControllerAttachedToCurrentParent(media)) {
        PSL.removeControllerFromMedia(media);
        new PSL.VideoController(media);
        return;
      }
      // The element already has this runtime's controller attached to its
      // current parent. YouTube reuses the SAME <video> element across
      // recommendation / SPA navigations (only currentSrc changes), and a
      // stale-controller sweep during the navigation churn can unregister the
      // element from state.mediaElements while leaving _pslControllerHandle
      // intact. Re-register (idempotent) so keyboard runAction — which iterates
      // state.mediaElements and skips handle-less entries — keeps reaching the
      // reused player after navigation. Without this, s/d silently stops working
      // on every video opened after the first.
      PSL.registerMedia(media);
      if (media._pslControllerHandle && media._pslControllerHandle.maintainSpeed) {
        media._pslControllerHandle.maintainSpeed("rescan");
      }
      return;
    }

    PSL.removeControllerFromMedia(media);

    if (!media._pslControllerHandle) {
      new PSL.VideoController(media);
    }
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-domain-store.js ----
(function (PSL) {
  function getSpeedDomain(video) {
    var doc = (video && video.ownerDocument) || document;
    var host = "";

    try {
      host = doc.location && doc.location.hostname;
    } catch (e) {}

    return String(host || "").replace(/^www\./, "");
  }

  // A speed worth persisting as a domain (site) speed: any non-zero finite speed
  // in the supported range. Negative (reverse) speeds are stored too so reverse
  // is maintained across navigation in domain mode; zero (pause) is a momentary
  // per-media state and is never stored.
  PSL.isStorableDomainSpeed = function (speed) {
    return (
      typeof speed === "number" &&
      Number.isFinite(speed) &&
      speed !== 0 &&
      speed >= -16 &&
      speed <= 16
    );
  };

  PSL.saveDomainSpeed = function (video, speed) {
    var domain = getSpeedDomain(video);
    var domainSpeeds;

    if (!domain || !PSL.isStorableDomainSpeed(speed)) {
      return;
    }

    domainSpeeds = Object.assign({}, PSL.settings.domainSpeeds || {});
    domainSpeeds[domain] = speed;
    PSL.settings.domainSpeeds = domainSpeeds;
    PSL.settings.lastSpeed = speed;
    PSL.setStorage(
      {
        lastSpeed: speed,
        domainSpeeds: domainSpeeds
      },
      function (error) {
        if (error) {
          PSL.log("Failed to save domain speed for " + domain + ": " + error, 3);
          return;
        }
        PSL.log("Domain speed setting saved for " + domain + ": " + speed, 5);
      }
    );
  };

  PSL.getSavedDomainSpeed = function (video) {
    var domain = getSpeedDomain(video);
    return domain && PSL.settings.domainSpeeds
      ? Number(PSL.settings.domainSpeeds[domain])
      : NaN;
  };

  PSL.shouldRestoreSavedDomainSpeed = function (video) {
    return (
      (!PSL.hasMediaSpeed || !PSL.hasMediaSpeed(video)) &&
      PSL.isStorableDomainSpeed(PSL.getSavedDomainSpeed(video))
    );
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/keep-speed-site-matcher.js ----
(function (PSL) {
  function normalizeKeepSpeedPattern(pattern) {
    pattern = String(pattern || "").replace(PSL.regStrip, "");
    if (!pattern) {
      return "";
    }
    try {
      if (/^https?:\/\//i.test(pattern)) {
        return new URL(pattern).hostname;
      }
    } catch (e) {}
    return pattern.replace(/^www\./, "");
  }

  PSL.buildKeepSpeedMatcher = function (pattern) {
    var normalized = normalizeKeepSpeedPattern(pattern);
    var regex;

    if (!normalized) {
      return null;
    }

    if (normalized.startsWith("/")) {
      try {
        regex = new RegExp(normalized.slice(1, -1));
      } catch (e) {
        return null;
      }
      return function (host) {
        return regex.test(host);
      };
    }

    return function (host) {
      return host === normalized || host.endsWith("." + normalized);
    };
  };

  PSL.buildKeepSpeedMatchers = function (patterns) {
    return String(patterns || "")
      .split("\n")
      .map(PSL.buildKeepSpeedMatcher)
      .filter(Boolean);
  };

  PSL.updateKeepSpeedSiteMatchers = function () {
    PSL.keepSpeedSiteMatchers = PSL.buildKeepSpeedMatchers(
      PSL.settings.keepSpeedSites
    );
  };

  PSL.isKeepSpeedSite = function (video) {
    var doc = video.ownerDocument || document;
    var host = "";

    try {
      host = (doc.location && doc.location.hostname || "").replace(/^www\./, "");
    } catch (e) {}

    return (PSL.keepSpeedSiteMatchers || []).some(function (matches) {
      return matches(host);
    });
  };

  PSL.updateKeepSpeedSiteMatchers();
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/keep-speed-site-enforcement.js ----
(function (PSL) {
  var KEEP_SPEED_ENFORCEMENT_INTERVAL_MS = 50;

  function isPositivePlaybackRate(speed) {
    return typeof speed === "number" && !isNaN(speed) && speed > 0 && speed <= 16;
  }

  PSL.stopPersistentSpeedEnforcement = function (video) {
    if (video._pslSpeedEnforcer) {
      clearInterval(video._pslSpeedEnforcer);
      video._pslSpeedEnforcer = null;
    }
  };

  PSL.startPersistentSpeedEnforcement = function (video) {
    PSL.stopPersistentSpeedEnforcement(video);
    // Persistent 50ms forcing runs ONLY on user-registered keep-speed sites
    // (e.g. niconico) that actively reset playbackRate. Everywhere else (YouTube
    // is never a keep-speed site by default) speed is held by set-once: the
    // direct assignment in setSpeed, the temporary 3s enforcement window, and the
    // page-world authority's event-driven restore. Starting an interval per video
    // here previously spawned one 50ms loop for every positive-rate media —
    // including each YouTube thumbnail preview — that accumulated and never
    // stopped.
    if (!PSL.isKeepSpeedSite(video)) {
      return;
    }
    video._pslSpeedEnforcer = setInterval(function () {
      var target = PSL.getMediaSpeed(video);
      if (
        !video.isConnected ||
        !isPositivePlaybackRate(target) ||
        video._pslPausedForZeroSpeed ||
        video._pslReversing
      ) {
        PSL.stopPersistentSpeedEnforcement(video);
        return;
      }
      // The 50ms keep-speed enforcer is a content-world writer that bypasses the
      // page-world setter guard. Trace every actual write (each one fires a
      // ratechange a site comment renderer can resync on); leave no-op ticks
      // untraced to avoid 20/sec flooding.
      if (Math.abs(video.playbackRate - target) > 0.001) {
        video.playbackRate = target;
      }
      PSL.updateSpeedIndicator(video);
    }, KEEP_SPEED_ENFORCEMENT_INTERVAL_MS);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/temporary-playback-rate-enforcement.js ----
(function (PSL) {
  PSL.shouldTemporarilyEnforceSpeed = function (video) {
    return (
      PSL.hasMediaSpeed &&
      PSL.hasMediaSpeed(video) &&
      video._pslEnforceSpeedUntil &&
      Date.now() < video._pslEnforceSpeedUntil
    );
  };

  PSL.enforceSpeedSoon = function (video) {
    if (!PSL.shouldTemporarilyEnforceSpeed(video)) {
      return;
    }

    setTimeout(function () {
      if (!PSL.shouldTemporarilyEnforceSpeed(video) || !video.isConnected) {
        return;
      }
      var speed = PSL.getMediaSpeed(video);
      if (speed > 0 && Math.abs(video.playbackRate - speed) > 0.001) {
        // 3s temporary-window content-world write (bypasses the page-world
        // setter guard). Trace it so post-change rate churn is attributable.
        video.playbackRate = speed;
      }
    }, 0);
  };

  PSL.expectPlaybackRateChange = function (video, speed) {
    video._pslExpectedPlaybackRate = speed;
  };

  PSL.consumeExpectedPlaybackRateChange = function (video) {
    var expected = video._pslExpectedPlaybackRate;
    if (
      typeof expected === "number" &&
      Math.abs(video.playbackRate - expected) < 0.001
    ) {
      video._pslExpectedPlaybackRate = undefined;
      return true;
    }
    return false;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/synthetic-speed-state.js ----
(function (PSL) {
  function rememberPauseStateBeforeSyntheticSpeed(video) {
    if (
      !PSL.isSyntheticPausedSpeed(video) &&
      typeof video._pslWasPausedBeforeSyntheticSpeed === "undefined"
    ) {
      video._pslWasPausedBeforeSyntheticSpeed = video.paused;
    }
  }

  function getReverseLoopBoundary(video) {
    var mediaKey = PSL.getMediaKey(video);
    var start = PSL.state.startTimes[mediaKey];
    var end = PSL.state.endTimes[mediaKey];

    if (
      PSL.state.loopsEnabled[mediaKey] &&
      typeof start === "number" &&
      typeof end === "number" &&
      end > start
    ) {
      return { start: start, end: end };
    }

    return null;
  }

  PSL.isSyntheticPausedSpeed = function (video) {
    return video._pslPausedForZeroSpeed || video._pslReversing;
  };

  PSL.stopReversePlayback = function (video) {
    if (PSL.reversePlaybackEngine) {
      PSL.reversePlaybackEngine.stop(video);
    }
    video._pslReversing = false;
  };

  PSL.clearSyntheticSpeedState = function (video) {
    PSL.stopReversePlayback(video);
    PSL.stopPersistentSpeedEnforcement(video);
    video._pslPausedForZeroSpeed = false;
    video._pslWasPausedBeforeSyntheticSpeed = undefined;
  };

  PSL.resumeAfterSyntheticSpeedIfNeeded = function (video, wasSyntheticSpeed) {
    var shouldResume =
      wasSyntheticSpeed && video._pslWasPausedBeforeSyntheticSpeed === false;

    PSL.clearSyntheticSpeedState(video);

    if (shouldResume) {
      var playPromise = video.play();
      if (playPromise && playPromise.catch) {
        playPromise.catch(function () {});
      }
    }
  };

  PSL.pauseAtZeroSpeed = function (video) {
    PSL.stopReversePlayback(video);
    PSL.stopPersistentSpeedEnforcement(video);
    rememberPauseStateBeforeSyntheticSpeed(video);
    video._pslPausedForZeroSpeed = true;
    video._pslEnforceSpeedUntil = 0;
    PSL.updateSpeedIndicator(video);
    video.pause();
  };

  PSL.startReversePlayback = function (video, speed) {
    var frameRate = PSL.getReverseFrameRate();
    var intervalMs = 1000 / frameRate;
    var stepSeconds = Math.abs(speed) / frameRate;
    var boundary = getReverseLoopBoundary(video);

    PSL.stopReversePlayback(video);
    rememberPauseStateBeforeSyntheticSpeed(video);

    if (!PSL.reversePlaybackEngine) {
      video._pslPausedForZeroSpeed = false;
      video._pslReversing = false;
      video._pslEnforceSpeedUntil = 0;
      return;
    }

    video._pslPausedForZeroSpeed = false;
    video._pslReversing = true;
    video._pslEnforceSpeedUntil = 0;
    PSL.updateSpeedIndicator(video);
    video.pause();
    PSL.reversePlaybackEngine.start(video, speed, {
      intervalMs: intervalMs,
      stepSeconds: stepSeconds,
      loopStart: boundary && boundary.start,
      loopEnd: boundary && boundary.end,
      onEnd: function () {
        PSL.setSpeed(video, 0);
      },
      onStop: function () {
        video._pslReversing = false;
      }
    });
  };

  PSL.getEffectivePlaybackRate = function (video) {
    return PSL.getMediaSpeed ? PSL.getMediaSpeed(video) : video.playbackRate;
  };

  PSL.stopSyntheticSpeed = function (video) {
    PSL.clearSyntheticSpeedState(video);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-actions.js ----
(function (PSL) {
  PSL.clampSpeed = function (speed, min, max) {
    var result = Math.min(Math.max(speed, min), max);
    return result;
  };

  PSL.getSpeedAdjustmentBase = function (video) {
    var result;
    if (PSL.getEffectivePlaybackRate) {
      result = PSL.getEffectivePlaybackRate(video);
    } else {
      result = video.playbackRate;
    }
    return result;
  };

  // Single source for the relative-step math (base ± delta, 1.0-crossing snap,
  // clamp/round). Shared by adjustSpeed's relative branch and the dispatcher's
  // multi-element aggregation, so both compute an identical target. Pass `base`
  // to reuse an already-read adjustment base and avoid a redundant read.
  PSL.computeRelativeTarget = function (video, delta, base) {
    var currentSpeed = typeof base === "number" ? base : PSL.getSpeedAdjustmentBase(video);
    var targetSpeed = currentSpeed + delta;

    if (
      (currentSpeed > 1.0 && targetSpeed < 1.0) ||
      (currentSpeed < 1.0 && targetSpeed > 1.0)
    ) {
      targetSpeed = 1.0;
    }

    return Number(PSL.clampSpeed(targetSpeed, -16, 16).toFixed(2));
  };

  PSL.adjustSpeed = function (video, value, options) {
    var relative;
    var currentSpeed;
    var targetSpeed;


    options = options || {};
    relative = options.relative === true;

    if (typeof value !== "number" || isNaN(value)) {
      return;
    }

    if (relative) {
      currentSpeed = PSL.getSpeedAdjustmentBase(video);
      targetSpeed = PSL.computeRelativeTarget(video, value, currentSpeed);
    } else {
      targetSpeed = Number(PSL.clampSpeed(value, -16, 16).toFixed(2));
    }
    PSL.setSpeed(video, targetSpeed);
  };

  PSL.setSpeed = function (video, speed) {
    if (PSL.settings.speedControlsEnabled === false) {
      return;
    }

    PSL.log("setSpeed started: " + speed, 5);
    var speedValue = speed.toFixed(2);
    var speedNumber = Number(speedValue);

    // Write the chosen non-zero speed to the selected scope layer — the single
    // source of truth that resolves the effective speed for every media. tab
    // survives SPA navigation in the same runtime; domain persists to storage
    // (below); video uses the per-media record written here. A negative
    // (reverse) speed is maintained too, so reverse carries across navigation;
    // the reverse engine clamps at currentTime 0 so it never rewinds past the
    // start. Zero (pause) is a momentary per-video state and never defines the
    // maintained layer.
    var selectedScope = PSL.resolveSpeedScope
      ? PSL.resolveSpeedScope(PSL.settings.speedScope)
      : "tab";
    if (speedNumber !== 0) {
      if (selectedScope === "tab" && PSL.state) {
        PSL.state.tabSpeed = speedNumber;
      } else if (selectedScope === "domain" && !video._pslApplyingStoredDomainSpeed) {
        // Persist here (not on the positive path below) so reverse speeds —
        // whose branch returns early — are stored and maintained too.
        PSL.saveDomainSpeed(video, speedNumber);
      }
    }
    var wasSyntheticSpeed = PSL.isSyntheticPausedSpeed(video);
    var recordScope = video._pslApplyingStoredDomainSpeed ? "site" : "temporary";
    PSL.setMediaSpeed(video, speedNumber, recordScope);

    if (speedNumber <= 0) {
      video._pslEnforceSpeedUntil = 0;
      video._pslExpectedPlaybackRate = undefined;
      PSL.stopPersistentSpeedEnforcement(video);
    }

    if (
      PSL.pageWorldBridge &&
      PSL.pageWorldBridge.isProbeEnabled &&
      PSL.pageWorldBridge.isProbeEnabled() &&
      PSL.pageWorldBridge.applySpeed
    ) {
      PSL.pageWorldBridge.applySpeed(video, speedNumber, recordScope, "set-speed");
      PSL.log("setSpeed sent to v2 media authority: " + speedNumber, 5);
    }

    if (speedNumber === 0) {
      PSL.pauseAtZeroSpeed(video);
      PSL.log("setSpeed paused media at zero speed", 5);
      return;
    }

    if (speedNumber < 0) {
      PSL.startReversePlayback(video, speedNumber);
      PSL.settings.lastSpeed = speedNumber;
      PSL.log("setSpeed started reverse media at " + speedNumber, 5);
      return;
    }

    video._pslEnforceSpeedUntil = Date.now() + 3000;
    try {
      PSL.expectPlaybackRateChange(video, speedNumber);
      video.playbackRate = speedNumber;
    } catch (e) {
      video._pslExpectedPlaybackRate = undefined;
      throw e;
    }
    PSL.updateSpeedIndicator(video);
    PSL.resumeAfterSyntheticSpeedIfNeeded(video, wasSyntheticSpeed);
    PSL.enforceSpeedSoon(video);
    PSL.startPersistentSpeedEnforcement(video);
    PSL.log("setSpeed finished: " + speed, 5);
  };

  PSL.resetSpeed = function (video, target) {
    if (Math.abs(PSL.getEffectivePlaybackRate(video) - target) < 0.001) {
      PSL.log("Resetting playback speed to 1.0", 4);
      PSL.setSpeed(video, 1.0);
    } else {
      PSL.log('Toggling playback speed to "preferred" speed', 4);
      PSL.setSpeed(video, target);
    }
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-apply.js ----
(function (PSL) {
  // Single application point for the maintained speed. Called on media attach and
  // on every source-change event. Reads the selected scope layer (the single
  // source of truth) and applies it to the element ONCE, so the user's speed —
  // including reverse — carries across SPA navigation and blob source swaps.
  // Replaces the former reassert / applyStoredDomainSpeed / applyLastSessionSpeed
  // / page-world carry-over fallback chain.
  PSL.applyEffectiveSpeed = function (video) {
    if (!video) {
      return false;
    }
    if (PSL.settings && PSL.settings.speedControlsEnabled === false) {
      return false;
    }

    var speed = PSL.getMaintainedSpeed ? PSL.getMaintainedSpeed(video) : null;
    if (typeof speed !== "number" || !Number.isFinite(speed) || speed === 0) {
      // No maintained value for the selected scope (or a momentary pause): leave
      // the new source at its native rate instead of stomping it.
      return false;
    }

    // Already at the maintained speed — avoid restarting the enforcement window
    // or the reverse engine on each redundant source event (loadstart/
    // loadeddata/canplay/emptied all fire maintainSpeed).
    if (speed > 0 && Math.abs((video.playbackRate || 0) - speed) < 0.001) {
      return false;
    }
    if (speed < 0 && video._pslReversing) {
      return false;
    }

    PSL.setSpeed(video, speed);
    return true;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/speed-domain-storage-listener.js ----
(function (PSL) {
  if (PSL.__domainSpeedStorageListenerInstalled) {
    return;
  }
  PSL.__domainSpeedStorageListenerInstalled = true;

  try {
    var storage =
      typeof browser !== "undefined" && browser.storage
        ? browser.storage
        : typeof chrome !== "undefined" && chrome.storage
          ? chrome.storage
          : null;

    if (!storage || !storage.onChanged) {
      return;
    }

    storage.onChanged.addListener(function (changes, areaName) {
      if (areaName && areaName !== "sync") {
        return;
      }
      if (!changes || !changes.domainSpeeds) {
        return;
      }

      PSL.settings.domainSpeeds = changes.domainSpeeds.newValue || {};
    });
  } catch (e) {}
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/playback-rate-change-listener.js ----
(function (PSL) {
  function updateSpeedFromEvent(video) {
    if (!video._pslControllerHandle || PSL.settings.speedControlsEnabled === false) {
      return;
    }

    if (video._pslReversing) {
      PSL.updateSpeedIndicator(video);
      return;
    }

    if (video._pslPausedForZeroSpeed) {
      PSL.updateSpeedIndicator(video);
      return;
    }

    if (
      PSL.getMediaSpeedScope &&
      PSL.getMediaSpeedScope(video) === "temporary"
    ) {
      PSL.updateSpeedIndicator(video);
      return;
    }

    var speed = Number(video.playbackRate.toFixed(2));
    var observation = PSL.observeNativePlaybackRate
      ? PSL.observeNativePlaybackRate(video, speed)
      : { action: "adopt", speed: speed };

    PSL.log("Playback rate changed to " + speed, 4);
    PSL.updateSpeedIndicator(video);
    if (video._pslControllerHandle && video._pslControllerHandle.blinkController) {
      video._pslControllerHandle.blinkController();
    }
  }

  PSL.setupRateChangeListener = function (doc) {
    if (doc._pslRateChangeListenerAttached === PSL.instanceId) {
      return;
    }
    if (doc._pslRateChangeHandler) {
      doc.removeEventListener("ratechange", doc._pslRateChangeHandler, true);
    }

    doc._pslRateChangeHandler = function (event) {
      var video = event.target;

      if (PSL.consumeExpectedPlaybackRateChange(video)) {
        PSL.log("Ignoring playback-rate change caused by setSpeed", 4);
        event.stopImmediatePropagation();
        return;
      }

      if (
        PSL.shouldTemporarilyEnforceSpeed(video) &&
        Math.abs(video.playbackRate - PSL.getMediaSpeed(video)) > 0.001
      ) {
        PSL.log("Reapplying target playback speed", 4);
        event.stopImmediatePropagation();
        PSL.enforceSpeedSoon(video);
        return;
      }

      updateSpeedFromEvent(video);
    };
    doc._pslRateChangeListenerAttached = PSL.instanceId;
    doc.addEventListener("ratechange", doc._pslRateChangeHandler, true);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/action-dispatcher.js ----
(function (PSL) {
  var PUBLIC_ACTIONS = [
    "slower",
    "faster",
    "reset",
    "mark-loop-range",
    "clear-loop"
  ];

  PSL.runAction = function (action, value, e) {
    PSL.log("runAction Begin", 5);

    if (PUBLIC_ACTIONS.indexOf(action) === -1) {
      PSL.log("Unknown public action ignored: " + action, 4);
      return;
    }

    if (!PSL.isActionEnabled(action)) {
      PSL.log("Action disabled by settings: " + action, 4);
      return;
    }

    if (PSL.actions && typeof PSL.actions[action] === "function") {
      PSL.actions[action](value, e);
      return;
    }

    var targetController = e ? e.target.getRootNode().host : null;

    // tab/domain scope share one layer base, so a per-element relative add chains
    // into ±step×N when several media co-reside (the +1.0 bug). Compute the
    // relative target ONCE from the first surviving element and apply it as an
    // absolute set to every survivor, so the net change is ±step regardless of N.
    // video scope keeps per-element relative (each has its own record).
    var selectedScope = PSL.resolveSpeedScope
      ? PSL.resolveSpeedScope(PSL.settings.speedScope)
      : "tab";
    var aggregateRelative = selectedScope === "tab" || selectedScope === "domain";
    var aggregateTarget = null;
    var aggregateTargetComputed = false;

    PSL.state.mediaElements.forEach(function (video) {
      var handle = video._pslControllerHandle;
      if (!handle) {
        return;
      }

      if (e && (!handle.matchesControllerElement || !handle.matchesControllerElement(targetController))) {
        return;
      }

      if (handle.showController) {
        handle.showController();
      }

      if (video.classList.contains("mpc-cancelled")) {
        return;
      }

      if (action === "faster" || action === "slower") {
        var step = PSL.getSpeedStep();
        var delta = action === "faster" ? step : -step;
        if (aggregateRelative) {
          // Compute the shared relative target once, then absolute-set every
          // survivor so N co-resident elements can't chain into ±step×N.
          if (!aggregateTargetComputed) {
            aggregateTarget = PSL.computeRelativeTarget(video, delta);
            aggregateTargetComputed = true;
          }
          PSL.adjustSpeed(video, aggregateTarget, { relative: false });
        } else {
          PSL.adjustSpeed(video, delta, { relative: true });
        }
      } else if (action === "reset") {
        PSL.resetSpeed(video, 1.0);
      } else if (action === "mark-loop-range") {
        PSL.markLoopRange(video, video.currentTime);
      } else if (action === "clear-loop") {
        PSL.clearLoop(video);
      }
    });
    PSL.log("runAction End", 5);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/keyboard-shortcuts.js ----
(function (PSL) {
  function getKeyboardEventKeyCode(event) {
    var result;
    if (event.keyCode) {
      result = event.keyCode;
    } else if (event.key && event.key.length === 1) {
      result = event.key.toUpperCase().charCodeAt(0);
    } else {
      result = 0;
    }
    return result;
  }

  PSL.attachKeyboardShortcuts = function (docs) {
    docs.forEach(function (doc) {
      if (doc._pslKeyboardShortcutsAttached === PSL.instanceId) {
        return;
      }
      if (doc._pslKeyboardShortcutsHandler) {
        doc.removeEventListener(
          "keydown",
          doc._pslKeyboardShortcutsHandler,
          true
        );
      }

      doc._pslKeyboardShortcutsHandler = function (event) {
        var keyCode = getKeyboardEventKeyCode(event);
        var item;
        PSL.log("Processing keydown event: " + keyCode, 6);

        if (
          !event.getModifierState ||
          event.getModifierState("Alt") ||
          event.getModifierState("Control") ||
          event.getModifierState("Fn") ||
          event.getModifierState("Meta") ||
          event.getModifierState("Hyper") ||
          event.getModifierState("OS")
        ) {
          PSL.log("Keydown event ignored due to active modifier: " + keyCode, 5);
          return;
        }

        if (
          event.target.nodeName === "INPUT" ||
          event.target.nodeName === "TEXTAREA" ||
          event.target.isContentEditable
        ) {
          return false;
        }

        if (!PSL.state.mediaElements.length) {
          if (PSL.attachControlsToExistingMedia) {
            PSL.attachControlsToExistingMedia(doc);
          }
          if (!PSL.state.mediaElements.length) {
            return false;
          }
        }

        item = PSL.settings.keyBindings.find(function (item) {
          return item.key === keyCode;
        });
        if (item) {
          PSL.runAction(item.action, item.value);
          if (item.force === "true") {
            event.preventDefault();
            event.stopPropagation();
          }
        }

        return false;
      };
      doc._pslKeyboardShortcutsAttached = PSL.instanceId;
      doc.addEventListener("keydown", doc._pslKeyboardShortcutsHandler, true);
    });
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/remove-stale-media-controls.js ----
(function (PSL) {
  // Read a controller's owning instanceId in a cross-world-safe way. Two
  // content-script worlds can share one document; a .mpc-controller created by
  // the other world exposes its id via the shared DOM dataset (dataset.pslInstance)
  // but NOT via the _pslInstanceId JS expando (expandos are per-world). Prefer
  // the dataset, fall back to the expando for same-world / older controllers.
  function readControllerInstance(controller) {
    try {
      if (controller.dataset && controller.dataset.pslInstance) {
        return controller.dataset.pslInstance;
      }
    } catch (e) {}
    try {
      return controller._pslInstanceId;
    } catch (e) {}
    return undefined;
  }

  PSL.removeStaleControllerElements = function (doc) {
    doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
      if (readControllerInstance(controller) !== PSL.instanceId) {
        controller.remove();
      }
    });
  };

  // Remove controllers whose bound media element is no longer in the live DOM.
  // SPA navigation (e.g. YouTube in a single tab) can swap out the player
  // <video>; the old controller is otherwise only cleaned by the debounced
  // MutationObserver, so a rescan can attach a new controller while the orphan
  // is still visible (duplicate panels). Sweeping detached-media controllers at
  // rescan time removes the orphan immediately instead of after the debounce.
  PSL.removeDetachedControllerElements = function (doc) {
    doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
      var media;
      try {
        media = controller._pslMediaElement;
      } catch (e) {}

      if (media && media.isConnected === false) {
        controller.remove();
        try {
          delete media._pslControllerHandle;
        } catch (e) {}
        if (PSL.unregisterMedia) {
          PSL.unregisterMedia(media);
        }
      }
    });
  };

  // Normalize controllers so that each live media element owns at most one
  // visible .mpc-controller. This generalizes the orphan/stale/detached sweeps
  // into one pass run at rescan time:
  //   1. live-set membership: remove controllers whose bound media is no longer
  //      reachable via findMediaElements (covers detached === isConnected:false
  //      AND media that left the scanned document/shadow tree but still reports
  //      connected, which the detached sweep cannot catch).
  //   2. stale instance: remove controllers from a previous content injection
  //      (_pslInstanceId !== PSL.instanceId), even on still-live media.
  //   3. dedupe: among controllers left on the same media, keep the one the
  //      media's current handle owns (matchesControllerElement); remove the rest.
  // Removal uses the same raw element .remove() as the existing sweeps; the
  // discarded duplicate wrappers have no back-reference to a VideoController, so
  // a full handle teardown is neither reachable nor attempted here (same
  // limitation as removeStale/removeDetached). The speed-view span is a child of
  // the wrapper and is removed with it.
  PSL.reconcileControllers = function (doc) {
    doc = doc || document;

    var live = new Set(PSL.findMediaElements(doc));

    function readMedia(controller) {
      try {
        return controller._pslMediaElement;
      } catch (e) {
        return undefined;
      }
    }
    function readInstance(controller) {
      return readControllerInstance(controller);
    }
    function drop(controller, media, reason, releaseMedia) {
      controller.remove();
      if (releaseMedia && media) {
        try {
          delete media._pslControllerHandle;
        } catch (e) {}
        if (PSL.unregisterMedia) {
          PSL.unregisterMedia(media);
        }
      }
    }

    // Pass 1: drop live-non-members and stale-instance controllers; collect the
    // rest grouped by their bound media for the dedupe pass.
    var byMedia = new Map();
    doc.querySelectorAll(".mpc-controller").forEach(function (controller) {
      var media = readMedia(controller);

      if (!media || !live.has(media)) {
        drop(controller, media, "not-live", true);
        return;
      }
      if (readInstance(controller) !== PSL.instanceId) {
        drop(controller, media, "stale-instance", false);
        return;
      }

      var bucket = byMedia.get(media);
      if (bucket) {
        bucket.push(controller);
      } else {
        byMedia.set(media, [controller]);
      }
    });

    // Pass 2: dedupe survivors per media, preferring the handle's own element.
    byMedia.forEach(function (controllers, media) {
      if (controllers.length < 2) {
        return;
      }
      var handle = media._pslControllerHandle;
      var keep = null;
      if (handle && handle.matchesControllerElement) {
        for (var i = 0; i < controllers.length; i++) {
          if (handle.matchesControllerElement(controllers[i])) {
            keep = controllers[i];
            break;
          }
        }
      }
      if (!keep) {
        keep = controllers[0];
      }
      controllers.forEach(function (controller) {
        if (controller !== keep) {
          drop(controller, media, "duplicate", false);
        }
      });
    });
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/attach-controls-to-existing-media.js ----
(function (PSL) {
  function attachControlsToExistingMedia(doc) {
    PSL.findMediaElements(doc).forEach(PSL.attachControllerToMedia);
  }

  PSL.attachControlsToExistingMedia = attachControlsToExistingMedia;

  PSL.rescanMedia = function (doc) {
    doc = doc || document;
    // Normalize controllers before re-attaching: one .mpc-controller per live
    // media. This synchronously removes swapped-out (detached), stale-instance,
    // and duplicate panels so they cannot coexist with the controller a rescan
    // is about to (re)attach (the debounced MutationObserver would otherwise
    // clean detached ones only ~1s later, and never the connected duplicates).
    if (PSL.reconcileControllers) {
      PSL.reconcileControllers(doc);
    } else if (PSL.removeDetachedControllerElements) {
      PSL.removeDetachedControllerElements(doc);
    }
    // Re-cover same-origin iframes that may have loaded since the last scan: a
    // lazy about:blank -> player iframe is not caught by the one-shot cover in
    // initializeNow. Idempotent (see coverMediaInsideFrames). This is the
    // top-only equivalent of each frame's own DOMContentLoaded re-injection.
    if (PSL.coverMediaInsideFrames) {
      PSL.coverMediaInsideFrames(doc);
    }
    if (PSL.loadSettings) {
      PSL.loadSettings(function () {
        if (PSL.settings.enabled) {
          attachControlsToExistingMedia(doc);
        }
      });
      return;
    }
    attachControlsToExistingMedia(doc);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/sync-controls-with-media-dom.js ----
(function (PSL) {
  function syncControlsForMediaNode(doc, node, added) {
    if (!added && doc.body.contains(node)) {
      return;
    }
    if (
      node.nodeName === "VIDEO" ||
      (node.nodeName === "AUDIO" && PSL.settings.audioBoolean)
    ) {
      if (added) {
        PSL.attachControllerToMedia(node);
      } else if (node._pslControllerHandle) {
        PSL.removeControllerFromMedia(node);
      }
    } else if (added && node.shadowRoot) {
      PSL.findMediaElements(node.shadowRoot).forEach(PSL.attachControllerToMedia);
    } else if (node.children !== undefined) {
      for (var i = 0; i < node.children.length; i++) {
        syncControlsForMediaNode(doc, node.children[i], added);
      }
    }
  }

  PSL.installMediaDomControlSync = function (doc) {
    var observer = new MutationObserver(function (mutations) {
      PSL.requestIdle(
        function () {
          mutations.forEach(function (mutation) {
            if (mutation.type !== "childList") {
              return;
            }
            mutation.addedNodes.forEach(function (node) {
              if (typeof node !== "function") {
                syncControlsForMediaNode(doc, node, true);
              }
            });
            mutation.removedNodes.forEach(function (node) {
              if (typeof node !== "function") {
                syncControlsForMediaNode(doc, node, false);
              }
            });
          });
        },
        { timeout: 1000 }
      );
    });

    observer.observe(doc, {
      childList: true,
      subtree: true
    });
    doc._pslMediaObserver = observer;
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/refresh-controls-after-same-page-navigation.js ----
(function (PSL) {
  var samePageRefreshDelays = [0, 100, 500, 1500];

  function refreshSamePageMediaControls(doc, reason) {
    if (PSL.pageWorldBridge && PSL.pageWorldBridge.installProbe) {
      PSL.pageWorldBridge.installProbe(doc);
    }
    samePageRefreshDelays.forEach(function (delay) {
      setTimeout(function () {
        PSL.rescanMedia(doc);
      }, delay);
    });
  }

  PSL.installSamePageMediaControlRefresh = function (doc) {
    if (doc !== window.document || window._pslUrlChangeSpeedRefreshInstalled) {
      return;
    }
    window._pslUrlChangeSpeedRefreshInstalled = PSL.instanceId;

    [
      "pageshow",
      "popstate",
      "hashchange",
      "yt-navigate-finish",
      "yt-page-data-updated"
    ].forEach(function (eventName) {
      window.addEventListener(eventName, function () {
        refreshSamePageMediaControls(doc, eventName);
      });
    });

    ["pushState", "replaceState"].forEach(function (methodName) {
      var original = window.history && window.history[methodName];
      if (typeof original !== "function" || original._pslWrapped) {
        return;
      }
      window.history[methodName] = function () {
        var result = original.apply(this, arguments);
        refreshSamePageMediaControls(doc, methodName);
        return result;
      };
      window.history[methodName]._pslWrapped = true;
    });
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/cover-media-inside-frames.js ----
(function (PSL) {
  function inIframe() {
    try {
      return window.self !== window.top;
    } catch (e) {
      return true;
    }
  }

  function injectFrameStylesheet(doc) {
    var assetUrl;
    var link;

    if (doc === window.document) {
      return;
    }

    assetUrl = PSL.getAssetUrl("extension/styles/content.css");
    if (!assetUrl) {
      return;
    }

    link = doc.createElement("link");
    link.href = assetUrl;
    link.type = "text/css";
    link.rel = "stylesheet";
    doc.head.appendChild(link);
  }

  PSL.getMediaControlKeyboardDocuments = function (doc) {
    var docs = [doc];
    try {
      if (inIframe()) {
        docs.push(window.top.document);
      }
    } catch (e) {}
    return docs;
  };

  PSL.coverMediaInsideFrames = function (doc) {
    injectFrameStylesheet(doc);

    // Top-only injection: the background injects the content bundle into the MAIN
    // frame only, so same-origin iframes have no runtime of their own. The top
    // runtime descends into each accessible iframe's contentDocument and
    // initializes it in-place — same JS world, same PSL.instanceId — so exactly
    // one panel and one keyboard handler set exist per logical player. Reaching
    // into a cross-origin iframe's contentDocument throws; those are skipped
    // (cross-origin players are out of scope — same-origin only). Re-entry is
    // idempotent: the _pslInitializedInstanceId guard in initializeNow no-ops an
    // already-initialized document, and we skip re-arming initializeWhenReady on
    // a document this runtime has already initialized.
    var frames = doc.getElementsByTagName("iframe");
    for (var i = 0; i < frames.length; i++) {
      try {
        var frameDoc = frames[i].contentDocument;
        if (frameDoc && frameDoc._pslInitializedInstanceId !== PSL.instanceId) {
          PSL.initializeWhenReady(frameDoc);
        }
      } catch (e) {
        // Cross-origin iframe: contentDocument is not accessible. Out of scope.
      }
    }

  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/start-media-control-session.js ----
(function (PSL) {
  PSL.initializeWhenReady = function (doc) {
    PSL.log("Begin initializeWhenReady", 5);
    if (PSL.isBlacklisted()) {
      return;
    }

    function initializeWhenBodyExists(attempt) {
      if (!doc) {
        return;
      }
      if (doc.body) {
        PSL.initializeNow(doc);
        return;
      }

      if (attempt < 20) {
        setTimeout(function () {
          initializeWhenBodyExists(attempt + 1);
        }, 50);
      }
    }

    if (doc === window.document) {
      window.addEventListener(
        "load",
        function () {
          PSL.initializeNow(window.document);
          PSL.rescanMedia(window.document);
        },
        { once: true }
      );
    }

    initializeWhenBodyExists(0);
    if (doc && doc.addEventListener) {
      doc.addEventListener(
        "DOMContentLoaded",
        function () {
          PSL.initializeNow(doc);
          PSL.rescanMedia(doc);
        },
        { once: true }
      );
    }
    if (doc === window.document && doc.addEventListener) {
      doc.addEventListener("visibilitychange", function () {
        if (!doc.visibilityState || doc.visibilityState === "visible") {
          PSL.rescanMedia(doc);
        }
      });
    }
    PSL.log("End initializeWhenReady", 5);
  };

  PSL.initializeNow = function (doc) {
    PSL.log("Begin initializeNow", 5);
    if (!PSL.settings.enabled) {
      return;
    }
    if (!doc.body || doc._pslInitializedInstanceId === PSL.instanceId) {
      return;
    }

    try {
      if (doc._pslMediaObserver) {
        doc._pslMediaObserver.disconnect();
      }
    } catch (e) {
      // Stale observers from a previous temporary add-on load can be ignored.
    }
    try {
      PSL.setupRateChangeListener(doc);
    } catch (e) {
      // no operation
    }
    doc._pslInitializedInstanceId = PSL.instanceId;
    doc.body.classList.add("mpc-initialized");
    PSL.log("initializeNow: mpc-initialized added to document body", 5);

    PSL.attachKeyboardShortcuts(PSL.getMediaControlKeyboardDocuments(doc));
    PSL.installMediaDomControlSync(doc);
    PSL.removeStaleControllerElements(doc);
    PSL.rescanMedia(doc);

    if (doc === window.document) {
      PSL.installSamePageMediaControlRefresh(doc);
    }

    PSL.coverMediaInsideFrames(doc);
    PSL.log("End initializeNow", 5);
  };
})(globalThis.PlaySpeedLoop);

// ---- code/extension/content/init.js ----
(function (PSL) {
  if (PSL.__injectionInitialized === PSL.injectionSessionId) {
    return;
  }
  PSL.__injectionInitialized = PSL.injectionSessionId;

  try {
    PSL.loadSettings(function () {
      try {
        PSL.initializeWhenReady(document);
      } catch (error) {
        console.error("Speed & Loop failed to initialize", error);
      }
    });
  } catch (error) {
    console.error("Speed & Loop failed to load settings", error);
    PSL.initializeWhenReady(document);
  }
})(globalThis.PlaySpeedLoop);

})();