// ==UserScript==
// @name Video Speed Buttons - Modern
// @description Add speed buttons to any HTML5 <video> element
// @namespace harubi
// @version 1.1.0
// @run-at document-end
// @author harubi
// @grant none
// @license MIT
//
// @match *://*.youtube.com/*
// @match *://youtube.com/*
// ==/UserScript==
class VideoSpeedController {
constructor() {
// Configuration
this.config = {
anchorSelector: '#above-the-fold',
videoSelector: 'video',
speeds: [0.25, 0.5, 1, 1.25, 1.5, 1.75, 2, 3, 4, 8, 16],
speedLabels: ['25%', '50%', 'Normal', '1.25', '1.5x', '1.75x', '2x', '3x', '4x', '8x', '16x'],
defaultSpeedIndex: 2, // Index of 1.0 in the speeds array
labelText: 'Video Speed: ',
selectedColor: '#FF5500',
normalColor: 'grey',
buttonSize: '120%'
};
// State
this.selectedIndex = this.config.defaultSpeedIndex;
this.buttons = [];
this.init();
}
init() {
// Find anchor and video elements
this.anchor = document.querySelector(this.config.anchorSelector);
this.videoEl = document.querySelector(this.config.videoSelector);
if (!this.anchor || !this.videoEl) {
console.error('[VideoSpeedController] Could not find anchor or video element');
// Try again in 1 second for dynamically loaded content
setTimeout(() => this.init(), 1000);
return;
}
this.createContainer();
this.anchor.insertBefore(this.container, this.anchor.firstChild);
this.setPlaybackRate(this.config.speeds[this.selectedIndex]);
this.setupKeyboardControls();
this.setupVideoObserver();
}
createContainer() {
// Create main container
this.container = document.createElement('div');
this.container.className = 'vsb-container';
Object.assign(this.container.style, {
borderBottom: '1px solid #ccc',
marginBottom: '10px',
paddingBottom: '10px'
});
// Add label
const label = document.createElement('span');
label.textContent = this.config.labelText;
Object.assign(label.style, {
marginRight: '10px',
fontWeight: 'bold',
fontSize: this.config.buttonSize,
color: this.config.normalColor
});
this.container.appendChild(label);
// Create speed buttons
this.config.speeds.forEach((speed, index) => {
const button = document.createElement('span');
button.textContent = this.config.speedLabels[index];
Object.assign(button.style, {
marginRight: '10px',
fontWeight: 'bold',
fontSize: this.config.buttonSize,
color: index === this.selectedIndex ? this.config.selectedColor : this.config.normalColor,
cursor: 'pointer'
});
button.addEventListener('click', () => this.selectSpeed(index));
this.buttons.push(button);
this.container.appendChild(button);
});
}
selectSpeed(index) {
// Deselect current button
this.buttons[this.selectedIndex].style.color = this.config.normalColor;
// Select new button
this.selectedIndex = index;
this.buttons[this.selectedIndex].style.color = this.config.selectedColor;
// Update playback rate
this.setPlaybackRate(this.config.speeds[this.selectedIndex]);
}
setPlaybackRate(rate) {
if (this.videoEl) {
this.videoEl.playbackRate = rate;
this.currentRate = rate;
}
}
isCommentBox(el) {
const commentBoxSelectors = ['.comment-simplebox-text', 'textarea'];
return commentBoxSelectors
.some(selector => el === document.querySelector(selector));
}
showHelp() {
const infobox = document.createElement('pre');
Object.assign(infobox.style, {
font: '1em monospace',
borderTop: '1px solid #ccc',
marginTop: '10px',
paddingTop: '10px'
});
infobox.innerHTML = `
Keyboard Controls (click to close)
[-] Speed Down
[+] Speed Up
[*] Reset Speed
[?] Show Help
`;
infobox.addEventListener('click', () => this.container.removeChild(infobox));
this.container.appendChild(infobox);
}
setupKeyboardControls() {
this.keydownHandler = (ev) => {
if (this.isCommentBox(ev.target)) return;
switch (ev.key) {
case '-':
if (this.selectedIndex > 0) {
this.selectSpeed(this.selectedIndex - 1);
}
break;
case '+':
if (this.selectedIndex < this.config.speeds.length - 1) {
this.selectSpeed(this.selectedIndex + 1);
}
break;
case '*':
this.selectSpeed(this.config.defaultSpeedIndex);
break;
case '?':
this.showHelp();
break;
}
};
document.body.addEventListener('keydown', this.keydownHandler);
}
setupVideoObserver() {
// Use MutationObserver instead of setInterval
this.observer = new MutationObserver(() => {
// Check if video element is still valid
if (!this.videoEl || !document.body.contains(this.videoEl)) {
const newVideo = document.querySelector(this.config.videoSelector);
if (newVideo && newVideo !== this.videoEl) {
this.videoEl = newVideo;
if (this.currentRate) {
this.setPlaybackRate(this.currentRate);
}
}
} else if (this.videoEl && this.videoEl.playbackRate !== this.currentRate) {
this.setPlaybackRate(this.currentRate);
}
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
destroy() {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
document.body.removeEventListener('keydown', this.keydownHandler);
if (this.observer) {
this.observer.disconnect();
}
}
}
// Initialize when document is ready
if (document.readyState === 'complete' || document.readyState === 'interactive') {
new VideoSpeedController();
} else {
document.addEventListener('DOMContentLoaded', () => {
new VideoSpeedController();
});
}