Greasy Fork is available in English.
Set Panopto playback speeds up to 16x.
// ==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);
})();