// ==UserScript==
// @name Youtube Keyboard Shortcuts: like/dislike, backup/restore position, change speed, picture-in-picture
// @match https://www.youtube.com/*
// @description Adds keyboard shortcuts [ and ] for liking and disliking videos, B and R to Back up and Restore position, H to use picture-in-picture, { and } to change playback speed.
// @author https://greasyfork.org/en/users/728793-keyboard-shortcuts
// @namespace http://tampermonkey.net/
// @version 1.11
// @grant none
// @license MIT
// @icon https://www.google.com/s2/favicons?domain=youtube.com&sz=128
// ==/UserScript==
/* jshint esversion: 6 */
/**
* Keyboard shortcuts on regular video player:
* [ to like a video
* ] to dislike it
* h to enable/disable Picture-in-Picture (PiP)
* b to back up the current time (where you are in the video)
* r to jump back to the backed up time ("r" for restore)
* shift-] – or "}" – to increase playback speed
* shift-[ – or "{" – to decrease playback speed
* p to go forward by 5 seconds
* o to go back by 5 seconds
* alt + p to go forward by 1 second
* alt + o to go back by 1 second
*
* Keyboard shortcuts on "shorts" video player are all of the above, plus:
* w to use the regular YouTube player if you're watching a short in the feed-based "Shorts" player
* Shift + W: switch to regular player, but open it in a new tab in order not to lose the current Shorts feed
*/
/****************************************************************************************************/
/* */
/* Change these constants to configure the script */
const PLAYBACK_RATE_STEP = 0.05; // change playback rate by 5% when pressing the shortcut keys (see above which keys those are)
const SHOW_NOTIFICATIONS = true;
const NOTIFICATIONS_INCLUDE_EMOJIS = true;
const NOTIFICATION_DURATION_MILLIS = 2000; // how long – in milliseconds – to keep a like/dislike notification visible.
const REMOVE_FEEDBACK_SHARED_WITH_CREATOR = true; // if true, remove the notification on dislike that says "feedback shared with the creator". Set to false to keep the default YouTube behavior.
/****************************************************************************************************/
var lastToastElement = null;
function showNotification(message) {
if (!SHOW_NOTIFICATIONS) {
return;
}
if (lastToastElement !== null) { // delete if still visible
lastToastElement.remove();
lastToastElement = null;
}
const toast = document.createElement('tp-yt-paper-toast');
toast.innerText = message;
toast.classList.add('toast-open');
const styleProps = {
outline: 'none',
position: 'fixed',
left: '0',
bottom: '12px',
maxWidth: '297.547px',
maxHeight: '48px',
zIndex: '2202',
opacity: '1',
};
for (const prop in styleProps) {
toast.style[prop] = styleProps[prop];
}
document.body.appendChild(toast);
lastToastElement = toast;
// needed otherwise the notification won't show
setTimeout(() => {
toast.style.display = 'block';
}, 0);
// preserves the animation
setTimeout(() => {
toast.style.transform = 'none';
}, 10);
setTimeout(() => {
toast.style.transform = 'translateY(200%)';
}, Math.max(0, NOTIFICATION_DURATION_MILLIS));
}
function removeBuiltInFeedbackShared(feedbackSharedStartTime) {
const now = Date.now();
for (const toastElement of Array.from(document.querySelectorAll('tp-yt-paper-toast'))) {
if (toastElement.textContent.toLowerCase().includes('feedback shared with the creator')) {
toastElement.remove();
return;
}
}
if (now - feedbackSharedStartTime < 1000) {
const intervalMs = (now - feedbackSharedStartTime < 100) ? 10 : 50; // faster at first
setTimeout(() => removeBuiltInFeedbackShared(feedbackSharedStartTime), intervalMs);
}
}
function getVideoId() {
const url = new URL(location.href);
if (pageIsRegularWatchViewer()) {
return url.searchParams.get('v');
} else if (pageIsYouTubeShortsViewer()) {
const match = /^\/(shorts)\/([0-9a-zA-Z-_]+)$/.exec(location.pathname);
return match ? match[2] : null;
} else {
return null;
}
}
function pageIsYouTubeShortsViewer() {
return /^\/(shorts)/.test(location.pathname);
}
function pageIsRegularWatchViewer() {
return /^\/(watch)/.test(location.pathname);
}
function isVisible(element) {
const rect = element.getBoundingClientRect();
const elemTop = rect.top;
const elemBottom = rect.bottom;
return (elemTop >= 0) && (elemBottom <= window.innerHeight);
}
function findRatingButtonShorts(outerSelector, innerSelector) {
return Array.from(document.querySelectorAll(outerSelector))
.filter(e => isVisible(e)) // make sure the element is on screen
.slice(0, 1) // keep only the first one
.map(renderer => renderer.querySelector(innerSelector)) // find the button inside
.find(_ => true) || null; // return the only match, or null if the array is empty (vs undefined for .find())
}
function findLikeDislikeButtons() {
if (pageIsYouTubeShortsViewer()) {
return {
like: findRatingButtonShorts('ytd-toggle-button-renderer#like-toggle-button', '#like-button button'),
dislike: findRatingButtonShorts('ytd-toggle-button-renderer#dislike-toggle-button', '#dislike-button button'),
};
} else if (!pageIsRegularWatchViewer()) {
return { like: null, dislike: null };
}
const directLikeButton = document.querySelector('div#segmented-like-button button');
const directDislikeButton = document.querySelector('div#segmented-dislike-button button');
if (directLikeButton && directDislikeButton) {
return {like: directLikeButton, dislike: directDislikeButton};
}
const infoRenderer = document.getElementsByTagName('ytd-video-primary-info-renderer');
const watchMetadata = document.getElementsByTagName('ytd-watch-metadata');
var like = null, dislike = null;
if (watchMetadata.length == 1) {
const buttons = Array.from(watchMetadata[0].getElementsByTagName('button'));
const paperButtons = Array.from(watchMetadata[0].getElementsByTagName('tp-yt-paper-button'));
const allButtons = buttons.concat(paperButtons);
for (var b of allButtons) {
if (b.hasAttribute('aria-label')) {
if (b.getAttribute('aria-label').toLowerCase().indexOf('dislike this') !== -1) {
dislike = b;
} else if (b.getAttribute('aria-label').toLowerCase().indexOf('like this') !== -1) {
like = b;
}
}
}
if (!like) {
like = buttons[4];
}
if (!dislike) {
dislike = buttons[5];
}
}
return {like, dislike};
}
function applyScrubSeconds(event, delta) {
const video = getVideoElement();
if (video) {
video.currentTime += delta;
event.preventDefault();
}
}
function getPressedAttribute(button) {
return button.hasAttribute('aria-pressed') ? button.getAttribute('aria-pressed') : null;
}
function formatTime(timeSec) {
const hours = Math.floor(timeSec / 3600.0);
const minutes = Math.floor((timeSec % 3600) / 60);
const seconds = Math.floor((timeSec % 60));
return (hours > 0 ? (('' + hours).padStart(2, '0') + ':') : '') +
('' + minutes).padStart(2, '0') + ':' +
('' + seconds).padStart(2, '0');
}
function formatPlaybackRate(rate) {
if (rate == Math.floor(rate)) { // integer
return rate + 'x';
} else if (rate.toFixed(2).endsWith('0')) { // float, 1 decimal place
return rate.toFixed(1) + 'x';
} else { // float, 2 decimal places max
return rate.toFixed(2) + 'x';
}
}
function maybePrefixWithEmoji(emoji, message) {
return NOTIFICATIONS_INCLUDE_EMOJIS ? emoji + ' ' + message : message;
}
function getVideoElement() {
const videos = Array.from(document.querySelectorAll('video')).filter(v => ! isNaN(v.duration)); // filter the invalid ones
return videos.length === 0 ? null : videos[0];
}
/** no Ctrl, Alt, or Meta/Cmd modifiers, but could have Shift pressed. */
function hasNoCtrlAltMetaModifiers(event) {
return !(event.ctrlKey || event.altKey || event.metaKey);
}
/** no modifiers whatsoever, so like above and also "not shift" */
function hasNoModifiers(event) {
return hasNoCtrlAltMetaModifiers(event) && (!event.shiftKey);
}
function keyPressIsZeroToNine(event) {
// we're looking for presses like {key: '2', code: 'Digit2'} with no modifiers
const zeroToNine = [...Array(10).keys()];
const expectedKeys = new Set(zeroToNine.map((i) => `${i}`));
const expectedCodes = new Set(zeroToNine.map((i) => `Digit${i}`));
return hasNoModifiers(event) &&
event.code === `Digit${event.key}` &&
expectedKeys.has(event.key) && expectedCodes.has(event.code);
}
function applyDeltaSec(video, deltaTimeSec) {
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + deltaTimeSec));
}
function switchFromShortsToClassicPlayer(videoId, video, openInNewTab) {
if (videoId !== null) {
const timeParam = (video === null ? '' : `&t=${ Math.floor(video.currentTime) }`);
const newURL = `https://youtube.com/watch?v=${ videoId }${ timeParam }`;
if (openInNewTab) {
video.pause(); // pause short as we open a new tab
window.open(newURL, '_blank');
} else {
location.href = newURL;
}
} else {
showNotification('Could not switch to the classic player');
}
}
function switchShortInOutOfFullScreen(video) {
if (document.fullscreenElement === video) {
document.exitFullscreen();
} else {
video.requestFullscreen();
}
}
function stopEvent(event) {
event.stopPropagation();
event.stopImmediatePropagation();
event.preventDefault();
}
function isContentEditable(element) {
const present = element.hasAttribute('contenteditable');
const value = element.getAttribute('contenteditable');
return (value === 'true' || (present && value === '')); // either set explicitly to true or set without a value
}
var pipEnabled = false; // initial value
const observer = new MutationObserver(findLikeDislikeButtons); // find buttons on DOM changes
observer.observe(document.documentElement, { childList: true, subtree: true });
var savedPosition = {};
// add keybindings
addEventListener('keydown', function (e) {
const tag = e.target.tagName.toLowerCase();
if (isContentEditable(e.target) || tag == 'input' || tag == 'textarea') {
return;
}
const player = document.getElementById('movie_player');
const buttons = findLikeDislikeButtons();
const video = getVideoElement();
const videoId = getVideoId();
if (e.code == 'BracketLeft' && (!e.ctrlKey) && (!e.shiftKey) && buttons.like) {
const likePressed = getPressedAttribute(buttons.like);
if (likePressed === 'true') {
showNotification(maybePrefixWithEmoji('😭', 'Removed like from video'));
} else if (likePressed === 'false') {
showNotification(maybePrefixWithEmoji('❤️', 'Liked video'));
}
buttons.like.click();
stopEvent(e);
} else if (e.code == 'BracketRight' && (!e.ctrlKey) && (!e.shiftKey) && buttons.dislike) {
const dislikePressed = getPressedAttribute(buttons.dislike);
if (dislikePressed === 'true') {
showNotification(maybePrefixWithEmoji('😐', 'Removed dislike from video'));
} else if (dislikePressed === 'false') {
showNotification(maybePrefixWithEmoji('💔', 'Disliked video'));
if (REMOVE_FEEDBACK_SHARED_WITH_CREATOR) {
removeBuiltInFeedbackShared(Date.now());
}
}
buttons.dislike.click();
stopEvent(e);
} else if (video && (e.code === 'KeyO' || e.code === 'KeyP')) {
const numSeconds = e.altKey ? 1 : 5;
const multiplier = e.code === 'KeyO' ? -1 : +1;
applyDeltaSec(video, multiplier * numSeconds);
} else if (e.code == 'KeyH') {
if (pipEnabled) {
document.exitPictureInPicture();
stopEvent(e);
} else if (video) {
video.requestPictureInPicture();
stopEvent(e);
}
pipEnabled = !pipEnabled;
} else if (video && e.code == 'KeyB') { // back up current position
savedPosition = { videoId: videoId, time: video.currentTime };
showNotification('Saved position: ' + formatTime(savedPosition.time));
stopEvent(e);
} else if (video && e.code == 'KeyR') { // restore saved position
if (videoId && savedPosition.videoId && savedPosition.videoId === videoId) {
video.currentTime = savedPosition.time;
stopEvent(e);
} else {
showNotification('No saved timestamp found');
}
} else if (player && e.shiftKey && e.code === 'BracketRight') {
player.setPlaybackRate(player.getPlaybackRate() + PLAYBACK_RATE_STEP);
showNotification('Playback rate:' + formatPlaybackRate(player.getPlaybackRate()));
stopEvent(e);
} else if (player && e.shiftKey && e.code === 'BracketLeft') {
player.setPlaybackRate(player.getPlaybackRate() - PLAYBACK_RATE_STEP);
showNotification('Playback rate:' + formatPlaybackRate(player.getPlaybackRate()));
stopEvent(e);
} else if (video && pageIsYouTubeShortsViewer()) { // specifically for the "Shorts" viewer:
if (e.code === 'KeyW' && hasNoCtrlAltMetaModifiers(e)) { // 'w' pressed, potentially with a "shift" modifier
switchFromShortsToClassicPlayer(videoId, video, e.shiftKey); // if shift is pressed, sets "openInNewTab" to true (last parameter)
} else if (hasNoModifiers(e)) { // Re-implement some features from the Watch page, since the Shorts viewer doesn't provide all the same built-in shortcuts:
if (keyPressIsZeroToNine(e)) { // support '0' … '9' to jump to the corresponding 0% to 90% of the duration
const percentage = parseInt(e.key) / 10;
video.currentTime = percentage * video.duration;
} else if (e.code === 'Comma' || e.code === 'Period') {
const fps = 30;
const deltaFrames = (e.code === 'Comma' ? -1 : +1);
applyDeltaSec(video, deltaFrames / fps);
} else if (e.code === 'KeyJ' || e.code === 'KeyL') {
applyDeltaSec(video, (e.code === 'KeyJ' ? -10 : +10));
} else if (e.code === 'KeyF') {
switchShortInOutOfFullScreen(video);
}
}
}
});