Panopto Speed Control

Set Panopto playback speeds up to 16x.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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