Add a speed toggle button to YouTube player controls, switch between 1x and 2x with one click
// ==UserScript==
// @name YouTube Speed Toggle
// @namespace https://github.com/ywtaoo
// @version 1.0.2
// @description Add a speed toggle button to YouTube player controls, switch between 1x and 2x with one click
// @author ywtaoo
// @license MIT
// @match https://www.youtube.com/*
// @icon https://www.youtube.com/favicon.ico
// @homepageURL https://github.com/ywtaoo/youtube_speed_toggle_shortcut
// @supportURL https://github.com/ywtaoo/youtube_speed_toggle_shortcut/issues
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const BUTTON_ID = 'yt-speed-toggle-btn';
const SPEEDS = [1, 2];
let currentSpeedIndex = 0;
let observedVideo = null;
/**
* Format playback speed for display
*/
function formatSpeed(speed) {
return parseFloat(speed.toFixed(2)) + 'x';
}
/**
* Get YouTube player instance
*/
function getPlayer() {
return document.querySelector('#movie_player');
}
/**
* Get current playback speed
*/
function getPlaybackSpeed() {
const player = getPlayer();
if (player && typeof player.getPlaybackRate === 'function') {
const playerSpeed = Number(player.getPlaybackRate());
if (!Number.isNaN(playerSpeed)) {
return playerSpeed;
}
}
const video = document.querySelector('video');
if (video) {
return video.playbackRate;
}
return null;
}
/**
* Set playback speed
*/
function setPlaybackSpeed(speed) {
const player = getPlayer();
const video = document.querySelector('video');
if (!player && !video) return false;
if (player && typeof player.setPlaybackRate === 'function') {
player.setPlaybackRate(speed);
}
if (video && video.playbackRate !== speed) {
video.playbackRate = speed;
}
return true;
}
/**
* Listen for speed changes on the current video element
*/
function observeVideoRateChanges() {
const video = document.querySelector('video');
if (!video || video === observedVideo) return;
if (observedVideo) {
observedVideo.removeEventListener('ratechange', syncButtonWithVideo);
}
observedVideo = video;
observedVideo.addEventListener('ratechange', syncButtonWithVideo);
}
/**
* Create speed toggle button
*/
function createSpeedButton() {
const button = document.createElement('button');
button.id = BUTTON_ID;
button.className = 'ytp-button';
button.title = 'Toggle playback speed';
button.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
width: auto;
min-width: 40px;
height: 100%;
padding: 0 8px;
font-size: 14px;
font-weight: 500;
color: #fff;
opacity: 0.9;
cursor: pointer;
transition: opacity 0.1s ease;
line-height: 1;
`;
button.textContent = '1x';
button.addEventListener('mouseenter', () => {
button.style.opacity = '1';
});
button.addEventListener('mouseleave', () => {
button.style.opacity = '0.9';
});
button.addEventListener('click', (e) => {
e.stopPropagation();
toggleSpeed();
});
return button;
}
/**
* Toggle playback speed
*/
function toggleSpeed() {
currentSpeedIndex = (currentSpeedIndex + 1) % SPEEDS.length;
const newSpeed = SPEEDS[currentSpeedIndex];
if (!setPlaybackSpeed(newSpeed)) return;
updateButtonDisplay(newSpeed);
}
/**
* Update button display
*/
function updateButtonDisplay(speed) {
const button = document.getElementById(BUTTON_ID);
if (button) {
const speedText = formatSpeed(speed);
button.textContent = speedText;
button.title = `Current speed: ${speedText} (click to toggle)`;
}
}
/**
* Sync button state with current video speed
*/
function syncButtonWithVideo() {
const button = document.getElementById(BUTTON_ID);
if (button) {
const currentRate = getPlaybackSpeed();
if (currentRate === null) return;
const speedIndex = SPEEDS.indexOf(currentRate);
if (speedIndex !== -1) {
currentSpeedIndex = speedIndex;
} else {
// If current speed is not in preset list, make the next click switch to 2x
currentSpeedIndex = 0;
}
updateButtonDisplay(currentRate);
}
observeVideoRateChanges();
}
/**
* Inject button into player controls
*/
function injectButton() {
// Check if button already exists
if (document.getElementById(BUTTON_ID)) {
syncButtonWithVideo();
return;
}
// Find right controls area (Delhi player uses sub-containers)
const targetContainer = document.querySelector('.ytp-right-controls-left')
|| document.querySelector('.ytp-right-controls');
if (!targetContainer) return;
const button = createSpeedButton();
// Insert at the first position of the controls container
targetContainer.insertBefore(button, targetContainer.firstChild);
// Sync current video speed
syncButtonWithVideo();
// Listen for video speed changes (user may change speed via other methods)
observeVideoRateChanges();
console.log('[YouTube Speed Toggle] Button injected');
}
/**
* Initialize script
*/
function init() {
// Try to inject immediately
injectButton();
// Use MutationObserver to watch for DOM changes
// YouTube is a SPA, content loads dynamically
const observer = new MutationObserver((mutations) => {
// Check if player exists but button doesn't
const player = document.querySelector('#movie_player');
const button = document.getElementById(BUTTON_ID);
if (player && !button) {
injectButton();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Listen for YouTube SPA navigation
// YouTube uses History API for page navigation
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
// Delay execution to wait for new page content to load
setTimeout(injectButton, 1000);
};
const originalReplaceState = history.replaceState;
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
setTimeout(injectButton, 1000);
};
window.addEventListener('popstate', () => {
setTimeout(injectButton, 1000);
});
// Listen for YouTube's yt-navigate-finish event (more reliable)
window.addEventListener('yt-navigate-finish', () => {
setTimeout(injectButton, 500);
});
}
// Start script
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();