Adds an immersive playback speed control interface to YouTube's bottom controls to extend the speed options, while also hiding the default one. Includes keyboard shortcuts.
// ==UserScript==
// @name YouTube Playback Speed Control++
// @namespace https://naeembolchhi.github.io/
// @version 0.6
// @description Adds an immersive playback speed control interface to YouTube's bottom controls to extend the speed options, while also hiding the default one. Includes keyboard shortcuts.
// @author NaeemBolchhi
// @license GPL-3.0-or-later
// @match https://www.youtube.com/*
// @icon data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 29 29"><path d="M14.48 24.5s9.08 0 11.34-.6a3.632 3.632 0 0 0 2.56-2.53c.62-2.22.62-6.89.62-6.89s0-4.64-.62-6.84a3.546 3.546 0 0 0-2.56-2.53c-2.25-.61-11.34-.61-11.34-.61s-9.06 0-11.31.61A3.658 3.658 0 0 0 .59 7.64c-.6 2.2-.6 6.84-.6 6.84s0 4.67.6 6.89a3.731 3.731 0 0 0 2.58 2.53c2.24.6 11.31.6 11.31.6Z" fill="%23f03"/><path d="m19 14.5-7.5-4.25v8.5L19 14.5Z" fill="%23fff"/></svg>
// @run-at document-body
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
// Available speed options
const speeds = [
[0.25, 0.5, 0.75, 1],
[1.25, 1.5, 1.75, 2],
[2.25, 2.5, 2.75, 3],
[3.25, 3.5, 3.75, 4]
];
// Define styles
const speedCSS = `
.ytp-speed-option, .ytp-speed-increase, .ytp-speed-decrease, .ytp-speed-insert {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.ytp-tooltip.ytp-bottom {
top: unset !important;
bottom: 70px;
}
.ytp-internal-speed, .ytp-speed-container.hidden {
display: none !important;
}
.ytp-speed-container.invisible {
opacity: 0;
}
.ytp-speed-container, .ytp-speed-row, .ytp-speed-option, .ytp-speed-display, .ytp-speed-increase, .ytp-speed-decrease {
display: flex;
align-items: center;
justify-content: center;
}
.ytp-speed-container {
opacity: 1;
flex-direction: column;
position: absolute;
right: 0;
bottom: 62px;
z-index: 9999;
background: var(--yt-sys-color-baseline--overlay-background-medium,rgba(0,0,0,.6));
backdrop-filter: var(--yt-frosted-glass-backdrop-filter-override,blur(16px));
color: rgb(255, 255, 255);
border-radius: 12px;
padding: 24px 16px 16px;
font-size: 14px;
gap: 8px;
transition: opacity .1s cubic-bezier(0,0,.2,1);
}
.ytp-speed-row {
gap: 8px;
}
.ytp-speed-option {
cursor: pointer;
height: 32px;
width: 53px;
border-radius: 16px;
background: rgba(255,255,255,.1);
}
.ytp-speed-display {
font-size: 18px;
font-weight: 900;
margin-bottom: 24px;
width: 80%;
text-align: center;
}
.ytp-speed-insert {
flex: 1 0 auto;
}
.ytp-speed-insert::after,
.ytp-speed-option::after {
content: 'x';
}
.ytp-speed-increase, .ytp-speed-decrease {
all: unset;
cursor: pointer;
font-size: 24px;
height: 32px;
width: 32px;
border-radius: 100%;
background: rgba(255,255,255,.1);
}
.ytp-speed-option:hover,.ytp-speed-increase:hover, .ytp-speed-decrease:hover {
background: rgba(255,255,255,.2);
}
.ytp-speed-button {
overflow: visible !important;
position: relative !important;
display: flex !important;
justify-content: center;
}
.ytp-speed-button svg {
transition: transform .1s cubic-bezier(.4,0,1,1);
}
.ytp-speed-button.opened svg {
transform: rotate(30deg);
}
.ytp-tooltip-text {
font-size: 13px;
width: 92px;
text-align: center;
}
.ytp-speed-tooltip {
position: absolute;
bottom: 62px !important;
display: none;
}
.ytp-speed-tooltip.hover {
display: unset;
}
.ytp-right-controls-left:has(>button[aria-expanded="true"]) .ytp-speed-tooltip,
.ytp-speed-button.opened .ytp-speed-tooltip {
display: none !important;
}
.ytp-chrome-bottom:has(.ytp-speed-button.opened) {
opacity: 1 !important;
}
div:has(>.ytp-bezel-text-wrapper)[class*="visible-"] {
display: unset !important;
}
div:has(>.ytp-bezel-text-wrapper)[class*="visible-"] .ytp-bezel-text-wrapper {
opacity: 1 !important;
}
div:has(>.ytp-bezel-text-wrapper)[class*="visible-"] .ytp-bezel-text-wrapper+* {
display: none !important;
}
#movie_player:not(:has(.ytp-speed-container.invisible)) .ytp-fullscreen-quick-actions {
display: none !important;
}
`;
// Create speed control
function createSpeedOptions() {
// Click hold variable for '+' and '-'
let holdTimeout, holdInterval;
const stopHolds = () => {
clearInterval(holdInterval);
clearTimeout(holdTimeout);
};
const speedContainer = document.createElement('div');
speedContainer.classList.add('ytp-speed-container','hidden','invisible');
// Speed display and buttons
const speedDisplay = document.createElement('div');
speedDisplay.classList.add('ytp-speed-display');
const speedDisplaySpan = document.createElement('span');
speedDisplaySpan.classList.add('ytp-speed-insert');
const speedDisplayDecrease = document.createElement('button');
speedDisplayDecrease.classList.add('ytp-speed-decrease');
speedDisplayDecrease.title = 'Decrease playback speed\nCtrl + , (Comma)';
speedDisplayDecrease.addEventListener('mousedown', () => {
changeSpeed('-');
holdTimeout = setTimeout(() => {
holdInterval = setInterval(() => {changeSpeed('-')}, 70);
}, 500);
});
speedDisplayDecrease.addEventListener('mouseup', stopHolds);
speedDisplayDecrease.addEventListener('mouseleave', stopHolds);
const speedDisplayIncrease = document.createElement('button');
speedDisplayIncrease.classList.add('ytp-speed-increase');
speedDisplayIncrease.title = 'Increase playback speed\nCtrl + . (Period)';
speedDisplayIncrease.addEventListener('mousedown', () => {
changeSpeed('+');
holdTimeout = setTimeout(() => {
holdInterval = setInterval(() => {changeSpeed('+')}, 70);
}, 500);
});
speedDisplayIncrease.addEventListener('mouseup', stopHolds);
speedDisplayIncrease.addEventListener('mouseleave', stopHolds);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
changeSpeed('-');
} else if (e.ctrlKey && e.key === '.') {
changeSpeed('+');
}
});
const speedDisplayDecreaseSpan = document.createElement('span');
speedDisplayDecreaseSpan.textContent = '–';
const speedDisplayIncreaseSpan = document.createElement('span');
speedDisplayIncreaseSpan.textContent = '+';
speedDisplayDecrease.appendChild(speedDisplayDecreaseSpan);
speedDisplayIncrease.appendChild(speedDisplayIncreaseSpan);
speedDisplay.appendChild(speedDisplayDecrease);
speedDisplay.appendChild(speedDisplaySpan);
speedDisplay.appendChild(speedDisplayIncrease);
speedContainer.appendChild(speedDisplay);
// Add speed options
for (let x = 0; x < speeds.length; x++) {
const speedRow = document.createElement('div');
speedRow.classList.add('ytp-speed-row');
for (let y = 0; y < speeds[x].length; y++) {
const speedOption = document.createElement('span');
speedOption.classList.add('ytp-speed-option');
speedOption.textContent = speeds[x][y];
speedOption.addEventListener('click', () => {
const video = document.querySelector('video');
if (video) {
// video.playbackRate = speeds[x][y];
setSpeedValue(speedOption.textContent);
}
});
speedRow.appendChild(speedOption);
}
speedContainer.appendChild(speedRow);
}
return speedContainer;
}
// Create speed button in chromeBottom
function createSpeedButton() {
const settingsButton = document.querySelector('.ytp-chrome-bottom .ytp-settings-button');
const speedContainer = document.querySelector('.ytp-speed-container');
const speedButton = document.createElement('button');
speedButton.classList.add('ytp-button', 'ytp-speed-button');
speedButton.appendChild(settingsButton.children[0].cloneNode(true));
speedButton.querySelector('path').setAttribute('d', "M12 1c1.44 0 2.87.28 4.21.83a11 11 0 0 1 3.45 2.27l-1.81 1.05c-3.78-3.23-9.46-2.79-12.69.99A8.986 8.986 0 0 0 3 12a9 9 0 0 0 18 0v-.44c-.03-.4-.08-.8-.15-1.2l1.81-1.05c1.49 5.89-2.08 11.87-7.98 13.36-1.36.34-2.78.42-4.17.23-6.02-.82-10.24-6.36-9.42-12.38C1.83 5.06 6.49.99 12 1Zm7.08 6.25-7.96 3.25a1.75 1.75 0 0 0-.92 2.28c.38.88 1.4 1.29 2.28.92.13-.06.25-.13.36-.21l6.8-5.26c.25-.19.29-.55.1-.8a.579.579 0 0 0-.66-.18h-.01Z");
const speedButtonTooltip = document.createElement('div');
speedButtonTooltip.classList.add('ytp-tooltip','ytp-bottom','ytp-speed-tooltip');
const speedButtonTooltip2 = document.createElement('div');
speedButtonTooltip2.classList.add('ytp-tooltip-text-wrapper');
const speedButtonTooltip3 = document.createElement('div');
speedButtonTooltip3.classList.add('ytp-tooltip-bottom-text');
const speedButtonTooltip4 = document.createElement('span');
speedButtonTooltip4.classList.add('ytp-tooltip-text');
speedButtonTooltip4.textContent = 'Playback speed';
speedButtonTooltip3.appendChild(speedButtonTooltip4);
speedButtonTooltip2.appendChild(speedButtonTooltip3);
speedButtonTooltip.appendChild(speedButtonTooltip2);
speedButton.appendChild(speedButtonTooltip);
speedButton.addEventListener('click', () => {
if (speedContainer.classList.contains('hidden')) {
speedContainer.classList.remove('hidden');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
speedContainer.classList.remove('invisible');
setTimeout(() => {
speedButton.classList.add('opened');
}, 100);
});
});
} else {
speedContainer.classList.add('invisible');
speedButton.classList.remove('opened');
setTimeout(() => {
speedContainer.classList.add('hidden');
}, 100);
}
});
document.body.addEventListener('click', (e) => {
if (e.target.closest('.ytp-speed-button.ytp-button') || e.target.closest('.ytp-speed-container') || speedContainer.classList.contains('hidden')) {return;}
speedContainer.classList.add('invisible');
speedButton.classList.remove('opened');
setTimeout(() => {
speedContainer.classList.add('hidden');
}, 100);
});
function hoverState() {
speedButtonTooltip.classList.add('hover');
}
function hoverStateGone() {
speedButtonTooltip.classList.remove('hover');
}
speedButton.addEventListener('mouseenter', () => {
hoverState();
});
speedButton.addEventListener('mouseleave', () => {
hoverStateGone();
});
speedButton.addEventListener('focus', () => {
hoverState();
});
speedButton.addEventListener('blur', () => {
hoverStateGone();
});
settingsButton.parentNode.insertBefore(speedButton, settingsButton);
}
// Deal with it when an option is clicked
function setSpeedValue(speedValue) {
const displayTextSpan = document.querySelector('.ytp-speed-insert');
displayTextSpan.textContent = parseFloat(speedValue).toFixed(2);
const video = document.querySelector('video');
video.playbackRate = parseFloat(speedValue);
bezelSpeedValue(speedValue);
}
// Display speed value in bezel
function bezelSpeedValue(speedValue) {
const bezel = document.querySelector('.ytp-bezel-text-wrapper');
const videoSpeed = parseFloat(speedValue).toFixed(2);
const dateNow = parseInt(Date.now()).toString();
bezel.children[0].textContent = videoSpeed + 'x';
bezel.parentNode.classList.add('visible-' + dateNow);
setTimeout(() => {
bezel.parentNode.classList.remove('visible-' + dateNow)
}, 1000);
}
// Insert speed options into the player
function insertSpeedOptions() {
const chromeBottom = document.querySelector('.ytp-chrome-bottom');
if (!chromeBottom || document.querySelector('.ytp-speed-container')) {return;}
// Safely add styles
GM_addStyle(speedCSS);
// Add speed menu
chromeBottom.appendChild(createSpeedOptions());
// Add speed button
createSpeedButton();
// Set initial highlight
setInitialSpeed();
}
// Initialize highlight speed options
function setInitialSpeed() {
const currentSpeed = document.querySelector('video')?.playbackRate || 1;
setSpeedValue(currentSpeed);
}
// Hide internal speed settings
function hideInternalSpeed() {
const chromeBottom = document.querySelector('.ytp-chrome-bottom');
if (!chromeBottom || document.querySelector('.ytp-internal-speed')) {return;}
const labels = document.querySelectorAll('#ytp-id-5 .ytp-menuitem-label');
for (let x = 0; x < labels.length; x++) {
if (labels[x].textContent.match(/playback speed/i)) {
labels[x].closest('.ytp-menuitem').classList.add('ytp-internal-speed');
}
}
}
// Change video speed
function changeSpeed(key) {
const changeRate = 0.05;
const currentSpeed = parseFloat(document.querySelector('video').playbackRate);
let newSpeed;
if (key === '-') {
newSpeed = currentSpeed - changeRate;
} else if (key === '+') {
newSpeed = currentSpeed + changeRate;
}
if (newSpeed < 0.05) {
newSpeed = 0.05;
}
setSpeedValue(newSpeed);
}
// Use MutationObserver to listen for player changes
const observer = new MutationObserver(() => {
insertSpeedOptions();
try {hideInternalSpeed()} catch {}
});
observer.observe(document.body, { childList: true, subtree: true });
// Execute after page loads
window.addEventListener('load', insertSpeedOptions);
})();