// ==UserScript==
// @name Better YouTube Video Controls
// @namespace http://tampermonkey.net/
// @version 1.3
// @description Enhanced YouTube playback with hold-for-speed controls and resume watching. Hold right arrow for fast playback, left for slow-mo, and save your place in videos!
// @author Henry Suen
// @match *://*.youtube.com/*
// @license MIT
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function() {
'use strict';
// --- USER CONFIGURABLE SETTINGS ---
// Get user settings or use defaults
let HOLD_RIGHT_PLAYBACK_SPEED = GM_getValue('holdRightPlaybackSpeed', 2.5);
let HOLD_LEFT_PLAYBACK_SPEED = GM_getValue('holdLeftPlaybackSpeed', 0.2);
let LONG_PRESS_THRESHOLD = GM_getValue('longPressThreshold', 250); // Default 250ms
let SKIP_SECONDS = GM_getValue('skipSeconds', 5); // Default 5 seconds
let RESUME_WATCHING = GM_getValue('resumeWatching', true); // Default to on
// Register menu commands for user configuration
GM_registerMenuCommand('Set Right Arrow Hold Speed (Fast)', function() {
const newSpeed = parseFloat(prompt('Enter playback speed when holding right arrow (e.g., 2.0, 3.0):', HOLD_RIGHT_PLAYBACK_SPEED));
if (!isNaN(newSpeed) && newSpeed > 0) {
HOLD_RIGHT_PLAYBACK_SPEED = newSpeed;
GM_setValue('holdRightPlaybackSpeed', newSpeed);
alert(`Right arrow hold speed set to ${newSpeed}x`);
} else {
alert('Invalid value. Please enter a positive number.');
}
});
GM_registerMenuCommand('Set Left Arrow Hold Speed (Slow)', function() {
const newSpeed = parseFloat(prompt('Enter playback speed when holding left arrow (e.g., 0.5, 0.75):', HOLD_LEFT_PLAYBACK_SPEED));
if (!isNaN(newSpeed) && newSpeed > 0 && newSpeed <= 1) {
HOLD_LEFT_PLAYBACK_SPEED = newSpeed;
GM_setValue('holdLeftPlaybackSpeed', newSpeed);
alert(`Left arrow hold speed set to ${newSpeed}x`);
} else {
alert('Invalid value. Please enter a number between 0 and 1.');
}
});
GM_registerMenuCommand('Set Skip Seconds', function() {
const newSkip = parseInt(prompt('Enter seconds to skip on right/left arrow tap:', SKIP_SECONDS));
if (!isNaN(newSkip) && newSkip > 0) {
SKIP_SECONDS = newSkip;
GM_setValue('skipSeconds', newSkip);
alert(`Skip seconds set to ${newSkip}`);
} else {
alert('Invalid value. Please enter a positive number.');
}
});
GM_registerMenuCommand('Toggle Resume Watching', function() {
RESUME_WATCHING = !RESUME_WATCHING;
GM_setValue('resumeWatching', RESUME_WATCHING);
alert(`Resume Watching: ${RESUME_WATCHING ? 'Enabled' : 'Disabled'}`);
});
// --- END OF USER CONFIGURABLE SETTINGS ---
// Store the original playback rate
let originalPlaybackRate = 1.0;
// Flag to track if we're handling a long press
let isLongPress = false;
// Track when the key was pressed
let keyDownTime = 0;
// Flag to ensure we only process one keydown at a time
let keyAlreadyDown = false;
// Track which key is being held
let activeKey = null;
// Reference to our speed indicator element
let speedIndicator = null;
// Reference to our timeout for detecting long press
let longPressTimeout = null;
// Our own UI indicator for actions
let actionIndicator = null;
// Timeout for hiding the action indicator
let hideActionTimeout = null;
// Variables for tracking video position
let currentVideoId = null;
let positionSaveInterval = null;
let lastSavedPosition = 0;
// Store the element that had focus before we blurred the progress bar
let lastActiveElement = null;
// --- Utility Functions ---
// Extract video ID from YouTube URL
function getVideoId() {
const url = window.location.href;
const match = url.match(/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/);
return match ? match[1] : null;
}
// Save current video position
function saveVideoPosition() {
const video = findYouTubeVideo();
if (!video || !RESUME_WATCHING) return;
const videoId = getVideoId();
if (!videoId) return;
// Only update if position changed significantly (more than 1 second)
if (Math.abs(video.currentTime - lastSavedPosition) > 1) {
lastSavedPosition = video.currentTime;
GM_setValue(`videoPosition_${videoId}`, video.currentTime);
}
}
// Start tracking video position
function startPositionTracking() {
if (!RESUME_WATCHING) return;
// Clear any existing interval
if (positionSaveInterval) {
clearInterval(positionSaveInterval);
}
// Set up the new interval
positionSaveInterval = setInterval(saveVideoPosition, 5000);
// Update the current video ID
currentVideoId = getVideoId();
}
// Restore video position
function restoreVideoPosition() {
if (!RESUME_WATCHING) return;
const videoId = getVideoId();
if (!videoId) return;
const savedPosition = GM_getValue(`videoPosition_${videoId}`, 0);
if (savedPosition > 0) {
const video = findYouTubeVideo();
if (video) {
// Don't resume if we're near the start or very close to where we left off
if (video.currentTime < 3 && savedPosition > 5) {
video.currentTime = savedPosition;
// Update YouTube's internal state to be aware of our time change
const ytplayer = findYouTubePlayer();
if (ytplayer && typeof ytplayer.seekTo === 'function') {
try {
ytplayer.seekTo(savedPosition, true);
} catch(e) {}
}
// Show a notification that we've resumed
showActionIndicator(`Resumed at ${formatTime(savedPosition)}`, 3000);
}
}
}
}
// Format time in MM:SS format
function formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
}
// Find video element on YouTube
function findYouTubeVideo() {
return document.querySelector('#movie_player video');
}
// Find YouTube player element
function findYouTubePlayer() {
return document.querySelector('#movie_player');
}
// Utility function to check if an element is an input field
function isInputField(element) {
if (!element) return false;
const tagName = element.tagName.toLowerCase();
const type = (element.type || '').toLowerCase();
return (tagName === 'input' &&
['text', 'password', 'email', 'number', 'search', 'tel', 'url'].includes(type)) ||
tagName === 'textarea' ||
element.isContentEditable;
}
// Check if focus is on progress bar
function isProgressBarFocused() {
// There are multiple elements that make up the progress bar
const progressBar = document.querySelector('.ytp-progress-bar');
const scrubber = document.querySelector('.ytp-scrubber-container');
const progressList = document.querySelectorAll('.ytp-progress-list');
if (document.activeElement === progressBar ||
document.activeElement === scrubber ||
(progressList && Array.from(progressList).includes(document.activeElement))) {
return true;
}
// Check for aria attributes that might indicate focus on progress controls
const activeElement = document.activeElement;
if (activeElement && (
activeElement.getAttribute('aria-valuemin') !== null ||
activeElement.getAttribute('aria-valuemax') !== null ||
activeElement.getAttribute('role') === 'slider'
)) {
// Check if it's in the player controls
const isInControls = activeElement.closest('.ytp-chrome-bottom');
return isInControls !== null;
}
return false;
}
// Remove focus from progress bar and trigger volume control
function handleVolumeKeyOnProgressBar(isVolumeUp) {
if (isProgressBarFocused()) {
// Store the active element so we can restore it later
lastActiveElement = document.activeElement;
// Blur the progress bar
if (lastActiveElement && lastActiveElement.blur) {
lastActiveElement.blur();
}
// Move focus to the player itself
const player = findYouTubePlayer();
if (player && player.focus) {
player.focus();
}
// Create and dispatch a synthetic key event to trigger YouTube's volume control
// We do this after ensuring focus is off the progress bar
setTimeout(() => {
const event = new KeyboardEvent('keydown', {
key: isVolumeUp ? 'ArrowUp' : 'ArrowDown',
code: isVolumeUp ? 'ArrowUp' : 'ArrowDown',
keyCode: isVolumeUp ? 38 : 40,
which: isVolumeUp ? 38 : 40,
bubbles: true,
cancelable: true,
composed: true
});
// Dispatch to player element to ensure YouTube's volume control is triggered
if (player) {
player.dispatchEvent(event);
} else {
// Fallback to document if player can't be found
document.dispatchEvent(event);
}
}, 10);
return true;
}
return false;
}
// --- UI Elements ---
// Create the speed indicator UI element
function createSpeedIndicator() {
// Remove any existing indicator first
removeSpeedIndicator();
// Create a new indicator
speedIndicator = document.createElement('div');
speedIndicator.id = 'speed-indicator';
speedIndicator.textContent = '1x'; // Default text - will be updated when shown
// Style the indicator with larger text
const style = speedIndicator.style;
style.position = 'absolute';
style.right = '20px';
style.top = '20px';
style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
style.color = 'white';
style.padding = '8px 16px'; // Larger padding
style.borderRadius = '6px';
style.fontSize = '28px'; // Large font size
style.fontWeight = 'bold';
style.zIndex = '9999';
style.display = 'none'; // Hidden by default
style.opacity = '0';
style.transition = 'opacity 0.3s ease';
// Add it to the player
const player = findYouTubePlayer();
if (player) {
player.appendChild(speedIndicator);
} else {
document.body.appendChild(speedIndicator); // Fallback
}
return speedIndicator;
}
// Create action indicator for volume and skip
function createActionIndicator() {
if (actionIndicator) {
return actionIndicator;
}
actionIndicator = document.createElement('div');
actionIndicator.id = 'action-indicator';
// Style the action indicator
const style = actionIndicator.style;
style.position = 'absolute';
style.left = '50%';
style.top = '50%';
style.transform = 'translate(-50%, -50%)';
style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
style.color = 'white';
style.padding = '12px 20px';
style.borderRadius = '8px';
style.fontSize = '24px';
style.fontWeight = 'bold';
style.zIndex = '10000';
style.display = 'none';
style.opacity = '0';
style.transition = 'opacity 0.3s ease';
// Add it to the player
const player = findYouTubePlayer();
if (player) {
player.appendChild(actionIndicator);
} else {
document.body.appendChild(actionIndicator);
}
return actionIndicator;
}
// Show the speed indicator with a fade-in effect
function showSpeedIndicator(speed) {
if (!speedIndicator) {
speedIndicator = createSpeedIndicator();
}
speedIndicator.textContent = speed + 'x'; // Update with current speed
speedIndicator.style.display = 'block';
setTimeout(() => {
speedIndicator.style.opacity = '1';
}, 10); // Small delay to ensure the transition works
}
// Hide the speed indicator with a fade-out effect
function hideSpeedIndicator() {
if (speedIndicator) {
speedIndicator.style.opacity = '0';
setTimeout(() => {
speedIndicator.style.display = 'none';
}, 300); // Wait for the transition to complete
}
}
// Remove the speed indicator completely
function removeSpeedIndicator() {
if (speedIndicator && speedIndicator.parentNode) {
speedIndicator.parentNode.removeChild(speedIndicator);
speedIndicator = null;
}
}
// Show action indicator
function showActionIndicator(text, duration = 1000) {
// Clear any existing hide timeout
if (hideActionTimeout) {
clearTimeout(hideActionTimeout);
hideActionTimeout = null;
}
const indicator = createActionIndicator();
// If indicator is already visible, just update text without the fade-out/fade-in
if (indicator.style.opacity === '1') {
indicator.textContent = text;
} else {
indicator.textContent = text;
indicator.style.display = 'block';
// Use a timeout to ensure the transition works
setTimeout(() => {
indicator.style.opacity = '1';
}, 10);
}
// Set a new timeout to hide the indicator
hideActionTimeout = setTimeout(() => {
indicator.style.opacity = '0';
setTimeout(() => {
indicator.style.display = 'none';
}, 300);
}, duration);
}
// --- Action Functions ---
// Function to perform a seek forward or backward
function performSeek(forward = true) {
const video = findYouTubeVideo();
if (!video) return false;
// Calculate new time
const currentTime = video.currentTime;
const newTime = currentTime + (forward ? SKIP_SECONDS : -SKIP_SECONDS);
const finalTime = Math.max(0, newTime);
// Update both the HTML5 video element and YouTube's internal state
video.currentTime = finalTime;
// Try to sync with YouTube's API
const player = findYouTubePlayer();
if (player && typeof player.seekTo === 'function') {
try {
player.seekTo(finalTime, true);
} catch(e) {}
}
// Show our custom UI indicator
showActionIndicator(`${forward ? 'Forward' : 'Backward'} ${SKIP_SECONDS}s`);
return true;
}
// Function to change playback speed
function changePlaybackSpeed(speed) {
const video = findYouTubeVideo();
if (!video) return false;
// Store original rate on first change
if (originalPlaybackRate === 1.0) {
originalPlaybackRate = video.playbackRate;
}
// Set new speed
video.playbackRate = speed;
// Also try to set through YouTube's API if available
const player = findYouTubePlayer();
if (player && typeof player.setPlaybackRate === 'function') {
try {
player.setPlaybackRate(speed);
} catch(e) {}
}
// Show indicator
showSpeedIndicator(speed);
return true;
}
// Function to reset playback speed
function resetPlaybackSpeed() {
const video = findYouTubeVideo();
if (!video) return false;
// Reset to original speed
video.playbackRate = originalPlaybackRate;
// Also try to reset through YouTube's API
const player = findYouTubePlayer();
if (player && typeof player.setPlaybackRate === 'function') {
try {
player.setPlaybackRate(originalPlaybackRate);
} catch(e) {}
}
// Hide speed indicator
hideSpeedIndicator();
return true;
}
// --- Key Event Handlers ---
// Main handler for key down events
const handleKeyDown = function(event) {
// Skip if we're in an input field
if (isInputField(document.activeElement)) return;
// Handle arrow keys
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
// Prevent default immediately
event.preventDefault();
event.stopPropagation();
// Store which key was pressed
activeKey = event.key;
// Only proceed if we found a video and it's the first keydown
if (findYouTubeVideo() && !keyAlreadyDown) {
keyAlreadyDown = true;
keyDownTime = Date.now();
// Set up a timeout to check for long press
longPressTimeout = setTimeout(() => {
// If key is still being pressed after threshold
if (keyAlreadyDown) {
isLongPress = true;
// Different behavior based on which arrow key
if (activeKey === 'ArrowRight') {
// Right arrow for fast playback
changePlaybackSpeed(HOLD_RIGHT_PLAYBACK_SPEED);
} else if (activeKey === 'ArrowLeft') {
// Left arrow for slow playback
changePlaybackSpeed(HOLD_LEFT_PLAYBACK_SPEED);
}
}
}, LONG_PRESS_THRESHOLD);
}
}
// Handle up/down arrow keys on progress bar
else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && isProgressBarFocused()) {
// Prevent default to avoid any time change
event.preventDefault();
event.stopPropagation();
// Handle volume control properly by fixing focus and dispatching a new event
handleVolumeKeyOnProgressBar(event.key === 'ArrowUp');
}
};
// Main handler for key up events
const handleKeyUp = function(event) {
// Skip if we're in an input field
if (isInputField(document.activeElement)) return;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
// Prevent default
event.preventDefault();
event.stopPropagation();
// Only process if this is the active key (prevents issues with multiple keys)
if (event.key === activeKey) {
// Clear the timeout to prevent speed change activation if key was released quickly
if (longPressTimeout) {
clearTimeout(longPressTimeout);
longPressTimeout = null;
}
// If this was a long press, reset playback speed
if (isLongPress) {
resetPlaybackSpeed();
} else if (keyAlreadyDown) {
// This was a quick tap, perform a seek
performSeek(event.key === 'ArrowRight');
}
// Reset tracking variables
isLongPress = false;
keyDownTime = 0;
keyAlreadyDown = false;
activeKey = null;
}
}
};
// --- Setup Functions ---
// More comprehensive event handling
function setupGlobalEventHandlers() {
// Capture all keyboard events at the window level
window.addEventListener('keydown', (e) => {
// Skip if we're in an input field to allow normal typing
if (isInputField(document.activeElement)) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
// Always handle left/right arrows
handleKeyDown(e);
// Always prevent propagation
e.stopPropagation();
e.preventDefault();
} else if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && isProgressBarFocused()) {
// For up/down, handle and prevent default when progress bar is focused
handleKeyDown(e);
e.stopPropagation();
e.preventDefault();
}
}, true);
window.addEventListener('keyup', (e) => {
// Skip if we're in an input field
if (isInputField(document.activeElement)) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
// Call our handler first
handleKeyUp(e);
// Always prevent propagation to YouTube
e.stopPropagation();
e.preventDefault();
}
}, true);
}
// Additional handler for the YouTube player specifically
function addYouTubePlayerHandlers() {
const player = findYouTubePlayer();
if (player) {
// Create our indicators now that we have the player
createSpeedIndicator();
createActionIndicator();
// Additional direct event listeners for the player
player.addEventListener('keydown', function(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
} else if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && isProgressBarFocused()) {
// For up/down on progress bar, handle and prevent
e.preventDefault();
e.stopPropagation();
handleVolumeKeyOnProgressBar(e.key === 'ArrowUp');
}
}, true);
// Try to restore video position
setTimeout(() => {
restoreVideoPosition();
startPositionTracking();
}, 1500); // Give YouTube a moment to initialize the video
}
}
// Function to handle YouTube video element being added or replaced
function handleVideoElementChange() {
const video = findYouTubeVideo();
if (video) {
// Try to restore video position
setTimeout(() => {
restoreVideoPosition();
startPositionTracking();
}, 1500); // Give YouTube a moment to initialize the video
}
}
// Setup functions that will need to be called once the page is loaded
function setupOnLoad() {
// Set up the global event handlers
setupGlobalEventHandlers();
// Add player-specific handlers
addYouTubePlayerHandlers();
// Also try to intercept YouTube's internal keyboard event handling
const originalDocKeyDown = document.onkeydown;
const originalDocKeyUp = document.onkeyup;
document.onkeydown = function(e) {
// Skip if we're in an input field
if (isInputField(document.activeElement)) {
return originalDocKeyDown ? originalDocKeyDown(e) : true;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
handleKeyDown(e);
return false; // Prevent default
} else if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && isProgressBarFocused()) {
// Completely prevent default when progress bar is focused
handleKeyDown(e);
return false;
}
return originalDocKeyDown ? originalDocKeyDown(e) : true;
};
document.onkeyup = function(e) {
// Skip if we're in an input field
if (isInputField(document.activeElement)) {
return originalDocKeyUp ? originalDocKeyUp(e) : true;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
handleKeyUp(e);
return false; // Prevent default
}
return originalDocKeyUp ? originalDocKeyUp(e) : true;
};
}
// Watch for DOM changes to catch the player element if it loads after the script
const observer = new MutationObserver(function(mutations) {
// Look for the player
if (!document.querySelector('#movie_player')) {
return;
}
// Check if we need to set up the player
if (!speedIndicator) {
addYouTubePlayerHandlers();
}
// Also watch for video element changes
const video = findYouTubeVideo();
if (video && currentVideoId != getVideoId()) {
handleVideoElementChange();
}
});
// Start observing the document
observer.observe(document, { childList: true, subtree: true });
// Listen for navigation events (YouTube is a SPA)
function handleNavigation() {
// Check for video ID change
const newVideoId = getVideoId();
if (newVideoId && newVideoId !== currentVideoId) {
currentVideoId = newVideoId;
// Clear existing tracking
if (positionSaveInterval) {
clearInterval(positionSaveInterval);
positionSaveInterval = null;
}
// Handle the video element for the new page
handleVideoElementChange();
}
}
// YouTube uses History API for navigation
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(this, arguments);
handleNavigation();
};
window.addEventListener('popstate', handleNavigation);
// Call setup once the page is fully loaded
if (document.readyState === 'complete') {
setupOnLoad();
} else {
window.addEventListener('load', setupOnLoad);
}
// Clean up when the page unloads
window.addEventListener('unload', function() {
// Save final position before unloading
saveVideoPosition();
removeSpeedIndicator();
if (actionIndicator && actionIndicator.parentNode) {
actionIndicator.parentNode.removeChild(actionIndicator);
}
observer.disconnect();
if (longPressTimeout) {
clearTimeout(longPressTimeout);
}
if (hideActionTimeout) {
clearTimeout(hideActionTimeout);
}
if (positionSaveInterval) {
clearInterval(positionSaveInterval);
}
});
})();