Panopto Speed Control

Set Panopto playback speeds up to 16x.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name        Panopto Speed Control
// @namespace   panopto-speed-control
// @version     1.1.3
// @description Set Panopto playback speeds up to 16x.
// @match       *://*.panopto.com/*
// @match       *://*.panopto.eu/*
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @license     MIT
// @run-at      document-idle
// ==/UserScript==

(function () {
  "use strict";

  const DEFAULT_EXTRA_SPEEDS = [2.5, 3, 3.5, 4, 5];
  const STEP = 0.25;
  const MIN_SPEED = 0.25;
  const MAX_SPEED = 16;
  const SPEED_KEY = "psc_speed";
  const EXTRAS_KEY = "psc_extra_speeds";
  const CUSTOM_KEY = "psc_custom_speed";

  let extraSpeeds = GM_getValue(EXTRAS_KEY, DEFAULT_EXTRA_SPEEDS);
  let currentSpeed = GM_getValue(SPEED_KEY, 1);
  let customSpeedValue = GM_getValue(CUSTOM_KEY, "");
  let videos = new Set();
  let applying = false;

  // --- style ---

  const style = document.createElement("style");
  style.textContent = `
    ul.MuiList-root.psc-two-col {
      columns: 2;
      column-gap: 0;
    }
    li.psc-col-break {
      break-after: column;
    }

    .psc-custom-input {
      width: 100%;
      border: none;
      border-bottom: 1px solid #dadce0;
      font-size: inherit;
      font-family: inherit;
      padding: 0;
      outline: none;
      background: transparent;
      color: inherit;
    }
    .psc-custom-input:focus {
      border-bottom-color: #1a73e8;
    }
    .psc-custom-input.psc-invalid {
      border-bottom-color: #d93025;
    }

    .psc-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.4);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 100000;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    }
    .psc-dialog {
      background: white;
      border-radius: 12px;
      padding: 20px;
      width: 260px;
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
      color: #202124;
      font-size: 13px;
    }
    .psc-dialog h2 {
      font-size: 14px;
      font-weight: 600;
      margin: 0 0 14px;
    }
    .psc-slot {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 8px;
    }
    .psc-slot label {
      font-size: 12px;
      color: #5f6368;
      min-width: 14px;
    }
    .psc-slot input {
      flex: 1;
      height: 32px;
      padding: 0 8px;
      border: 1px solid #dadce0;
      border-radius: 6px;
      font-size: 13px;
      font-family: inherit;
      outline: none;
      transition: border-color 0.15s;
      background: white;
      color: #202124;
    }
    .psc-slot input:focus {
      border-color: #1a73e8;
    }
    .psc-slot input.psc-invalid {
      border-color: #d93025;
    }
    .psc-suffix {
      font-size: 13px;
      color: #5f6368;
    }
    .psc-actions {
      display: flex;
      gap: 8px;
      margin-top: 14px;
    }
    .psc-actions button {
      flex: 1;
      height: 32px;
      border: none;
      border-radius: 6px;
      font-size: 13px;
      font-family: inherit;
      cursor: pointer;
      transition: background 0.15s;
    }
    .psc-btn-save {
      background: #1a73e8;
      color: white;
    }
    .psc-btn-save:hover {
      background: #1557b0;
    }
    .psc-btn-reset {
      background: #f1f3f4;
      color: #5f6368;
    }
    .psc-btn-reset:hover {
      background: #e8eaed;
    }
    .psc-saved {
      text-align: center;
      font-size: 12px;
      color: #188038;
      margin-top: 8px;
      opacity: 0;
      transition: opacity 0.2s;
    }
    .psc-saved.psc-visible {
      opacity: 1;
    }
  `;
  document.head.appendChild(style);

  // --- persistence ---

  function saveSpeed(speed) {
    GM_setValue(SPEED_KEY, speed);
  }

  // --- config dialog ---

  function openConfig() {
    if (document.querySelector(".psc-overlay")) return;

    const overlay = document.createElement("div");
    overlay.className = "psc-overlay";
    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) overlay.remove();
    });

    const dialog = document.createElement("div");
    dialog.className = "psc-dialog";

    const heading = document.createElement("h2");
    heading.textContent = "Extra Speeds";
    dialog.appendChild(heading);

    const slotsDiv = document.createElement("div");
    const inputs = [];

    function buildSlots(values) {
      slotsDiv.innerHTML = "";
      inputs.length = 0;
      for (let i = 0; i < 5; i++) {
        const slot = document.createElement("div");
        slot.className = "psc-slot";

        const label = document.createElement("label");
        label.textContent = i + 1;

        const input = document.createElement("input");
        input.type = "number";
        input.step = "0.25";
        input.min = "0.25";
        input.max = "16";
        input.value = values[i] ?? "";
        input.addEventListener("input", () => {
          const v = parseFloat(input.value);
          const valid = !isNaN(v) && v >= 0.25 && v <= 16;
          input.classList.toggle("psc-invalid", !valid && input.value !== "");
        });

        const suffix = document.createElement("span");
        suffix.className = "psc-suffix";
        suffix.textContent = "x";

        slot.append(label, input, suffix);
        slotsDiv.appendChild(slot);
        inputs.push(input);
      }
    }

    buildSlots(extraSpeeds);
    dialog.appendChild(slotsDiv);

    const actions = document.createElement("div");
    actions.className = "psc-actions";

    const resetBtn = document.createElement("button");
    resetBtn.className = "psc-btn-reset";
    resetBtn.textContent = "Reset";
    resetBtn.addEventListener("click", () => {
      buildSlots(DEFAULT_EXTRA_SPEEDS);
      extraSpeeds = [...DEFAULT_EXTRA_SPEEDS];
      GM_setValue(EXTRAS_KEY, extraSpeeds);
      flashSaved();
    });

    const saveBtn = document.createElement("button");
    saveBtn.className = "psc-btn-save";
    saveBtn.textContent = "Save";
    saveBtn.addEventListener("click", () => {
      const allValid = inputs.every((inp) => {
        const v = parseFloat(inp.value);
        return !isNaN(v) && v >= 0.25 && v <= 16;
      });
      if (!allValid) return;

      const speeds = [];
      for (const inp of inputs) {
        const v = parseFloat(inp.value);
        if (!isNaN(v) && v >= 0.25 && v <= 16) {
          speeds.push(Math.round(v * 100) / 100);
        }
      }
      extraSpeeds = speeds;
      GM_setValue(EXTRAS_KEY, extraSpeeds);
      flashSaved();
    });

    actions.append(resetBtn, saveBtn);
    dialog.appendChild(actions);

    const savedMsg = document.createElement("div");
    savedMsg.className = "psc-saved";
    savedMsg.textContent = "Saved";
    dialog.appendChild(savedMsg);

    function flashSaved() {
      savedMsg.classList.add("psc-visible");
      setTimeout(() => savedMsg.classList.remove("psc-visible"), 1500);
    }

    overlay.appendChild(dialog);
    document.body.appendChild(overlay);
  }

  GM_registerMenuCommand("Configure Extra Speeds", openConfig);

  // --- speed control ---

  const BUILTIN_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];

  function isPresetSpeed(speed) {
    const all = [...BUILTIN_SPEEDS, ...extraSpeeds];
    return all.some((s) => Math.abs(s - speed) < 0.01);
  }

  function formatSpeed(s) {
    return s % 1 === 0 ? s + "x" : s.toFixed(2).replace(/0$/, "") + "x";
  }

  function updateSpeedDisplay() {
    const desired = formatSpeed(currentSpeed);
    const textEls = document.querySelectorAll("[class*='MuiListItemText-root']");
    for (const el of textEls) {
      if (el.textContent.trim() !== "Speed") continue;
      const siblings = el.parentElement.querySelectorAll("[class*='MuiListItemText-root']");
      for (const sib of siblings) {
        if (sib !== el && /^\d+(\.\d+)?x$/.test(sib.textContent.trim())) {
          if (sib.textContent.trim() !== desired) {
            sib.textContent = desired;
          }
          return;
        }
      }
    }
  }

  function applySpeed(speed) {
    currentSpeed = Math.round(speed * 100) / 100;
    applying = true;
    for (const video of videos) {
      video.playbackRate = currentSpeed;
    }
    applying = false;
    saveSpeed(currentSpeed);
  }

  function nudgeSpeed(delta) {
    const max = Math.max(MAX_SPEED, ...extraSpeeds);
    const next = Math.min(max, Math.max(MIN_SPEED, currentSpeed + delta));
    applySpeed(next);
  }

  // --- video tracking ---

  function trackVideo(video) {
    if (videos.has(video)) return;
    videos.add(video);
    video.playbackRate = currentSpeed;

    video.addEventListener("ratechange", () => {
      if (!applying && Math.abs(video.playbackRate - currentSpeed) > 0.01) {
        video.playbackRate = currentSpeed;
      }
    });
  }

  function scanForVideos() {
    for (const v of document.querySelectorAll("video")) {
      trackVideo(v);
    }
  }

  // --- menu injection ---

  function isSpeedMenu(ul) {
    const items = ul.querySelectorAll("li");
    let found2x = false;
    let found1x = false;
    for (const li of items) {
      const text = li.textContent.trim();
      if (text === "2x") found2x = true;
      if (text === "1x") found1x = true;
    }
    return found2x && found1x;
  }

  function alreadyInjected(ul) {
    return ul.querySelector("[data-psc-injected]") !== null;
  }

  function closeMenu() {
    const backdrop = document.querySelector("[class*='MuiBackdrop-root']");
    if (backdrop) backdrop.click();
  }

  function injectSpeeds(ul) {
    if (alreadyInjected(ul)) {
      updateCheckmarks(ul);
      return;
    }

    const sorted = [...extraSpeeds].sort((a, b) => b - a);

    const items = Array.from(ul.querySelectorAll("li[role='menuitem']"));
    const twoXItem = items.find((li) => {
      const textEl = li.querySelector("[class*='MuiListItemText']");
      return textEl && textEl.textContent.trim() === "2x";
    });
    if (!twoXItem) return;

    const templateItem = items.find((li) => !li.hasAttribute("aria-current")) || items[0];

    ul.classList.add("psc-two-col");

    for (let i = 0; i < sorted.length; i++) {
      const speed = sorted[i];
      const newItem = templateItem.cloneNode(true);
      newItem.setAttribute("data-psc-injected", "true");
      newItem.setAttribute("data-psc-speed", String(speed));
      newItem.removeAttribute("aria-current");
      newItem.setAttribute("tabindex", "-1");
      newItem.className = templateItem.className;

      const textEl = newItem.querySelector("[class*='MuiListItemText']");
      if (textEl) textEl.textContent = formatSpeed(speed);

      const iconEl = newItem.querySelector("[class*='MuiListItemIcon']");
      if (iconEl) iconEl.remove();

      newItem.addEventListener("click", (e) => {
        e.stopPropagation();
        applySpeed(speed);
        updateCheckmarks(ul);
      });

      ul.insertBefore(newItem, twoXItem);

      if (i === sorted.length - 1) {
        newItem.classList.add("psc-col-break");
      }
    }

    for (const li of items) {
      const textEl = li.querySelector("[class*='MuiListItemText']");
      if (!textEl) continue;
      const match = textEl.textContent.trim().match(/^([\d.]+)x$/);
      if (!match) continue;
      const speed = parseFloat(match[1]);
      li.addEventListener("click", (e) => {
        e.stopPropagation();
        e.preventDefault();
        applySpeed(speed);
        updateCheckmarks(ul);
      }, true);
    }

    const customItem = templateItem.cloneNode(true);
    customItem.setAttribute("data-psc-injected", "true");
    customItem.setAttribute("data-psc-custom", "true");
    customItem.removeAttribute("aria-current");
    customItem.setAttribute("tabindex", "-1");
    customItem.className = templateItem.className;

    const customIconEl = customItem.querySelector("[class*='MuiListItemIcon']");
    if (customIconEl) customIconEl.remove();

    const customTextEl = customItem.querySelector("[class*='MuiListItemText']");
    if (customTextEl) {
      customTextEl.textContent = "";
      const customInput = document.createElement("input");
      customInput.type = "text";
      customInput.className = "psc-custom-input";
      customInput.placeholder = "Custom";

      if (customSpeedValue) {
        customInput.value = customSpeedValue;
      }

      customInput.addEventListener("keydown", (e) => {
        e.stopPropagation();
        if (e.key === "Enter") {
          const raw = customInput.value.replace(/x$/i, "").trim();
          const v = parseFloat(raw);
          if (!isNaN(v) && v >= MIN_SPEED && v <= MAX_SPEED) {
            customInput.classList.remove("psc-invalid");
            customSpeedValue = formatSpeed(v);
            GM_setValue(CUSTOM_KEY, customSpeedValue);
            applySpeed(v);
            customInput.value = customSpeedValue;
            updateCheckmarks(ul);
            updateSpeedDisplay();
          } else {
            customInput.classList.add("psc-invalid");
          }
        }
      });

      customTextEl.appendChild(customInput);

      customItem.addEventListener("click", (e) => {
        if (e.target === customInput) return;
        e.stopPropagation();
        const raw = customInput.value.replace(/x$/i, "").trim();
        const v = parseFloat(raw);
        if (!isNaN(v) && v >= MIN_SPEED && v <= MAX_SPEED) {
          applySpeed(v);
          updateCheckmarks(ul);
          updateSpeedDisplay();
        }
      });
    }

    // Place custom item after the last extra speed, move column break to it
    const lastExtra = ul.querySelector("li.psc-col-break");
    if (lastExtra) {
      lastExtra.classList.remove("psc-col-break");
      customItem.classList.add("psc-col-break");
      lastExtra.after(customItem);
    } else {
      customItem.classList.add("psc-col-break");
      ul.insertBefore(customItem, twoXItem);
    }

    updateCheckmarks(ul);
  }

  function updateCheckmarks(ul) {
    const allItems = ul.querySelectorAll("li[role='menuitem']");
    const checkmarkIcon = ul.querySelector("[class*='MuiListItemIcon']");
    const customLi = ul.querySelector("[data-psc-custom]");
    const customIsActive = !isPresetSpeed(currentSpeed);

    for (const li of allItems) {
      if (li === customLi) continue;

      const textEl = li.querySelector("[class*='MuiListItemText']");
      if (!textEl) continue;
      const match = textEl.textContent.trim().match(/^([\d.]+)x$/);
      if (!match) continue;
      const speed = parseFloat(match[1]);
      const isActive = Math.abs(speed - currentSpeed) < 0.01;

      if (isActive) {
        li.setAttribute("aria-current", "true");
        li.setAttribute("tabindex", "0");
        if (!li.querySelector("[class*='MuiListItemIcon']") && checkmarkIcon) {
          li.appendChild(checkmarkIcon.cloneNode(true));
        }
      } else {
        li.removeAttribute("aria-current");
        li.setAttribute("tabindex", "-1");
        const icon = li.querySelector("[class*='MuiListItemIcon']");
        if (icon) icon.remove();
      }
    }

    if (customLi) {
      if (customIsActive) {
        customLi.setAttribute("aria-current", "true");
        customLi.setAttribute("tabindex", "0");
        if (!customLi.querySelector("[class*='MuiListItemIcon']") && checkmarkIcon) {
          customLi.appendChild(checkmarkIcon.cloneNode(true));
        }
        const input = customLi.querySelector(".psc-custom-input");
        if (input && !input.matches(":focus")) {
          input.value = formatSpeed(currentSpeed);
        }
      } else {
        customLi.removeAttribute("aria-current");
        customLi.setAttribute("tabindex", "-1");
        const icon = customLi.querySelector("[class*='MuiListItemIcon']");
        if (icon) icon.remove();
      }
    }
  }

  function watchForSpeedMenu() {
    const observer = new MutationObserver(() => {
      const lists = document.querySelectorAll("ul.MuiList-root[role='menu']");
      for (const ul of lists) {
        if (isSpeedMenu(ul)) {
          injectSpeeds(ul);
        }
      }
      updateSpeedDisplay();
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // --- keyboard shortcuts ---

  function onKeyDown(e) {
    const tag = e.target.tagName;
    if (tag === "INPUT" || tag === "TEXTAREA" || e.target.isContentEditable) {
      return;
    }
    if (e.key === "[") {
      e.preventDefault();
      nudgeSpeed(-STEP);
    } else if (e.key === "]") {
      e.preventDefault();
      nudgeSpeed(STEP);
    }
  }

  // --- init ---

  scanForVideos();

  const videoObserver = new MutationObserver(() => scanForVideos());
  videoObserver.observe(document.body, { childList: true, subtree: true });

  watchForSpeedMenu();
  document.addEventListener("keydown", onKeyDown);
})();