YouTube Keyboard Shortcuts: like/dislike, backup/restore position, change speed, picture-in-picture…

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.

// ==UserScript==
// @name         YouTube Keyboard Shortcuts: like/dislike, backup/restore position, change speed, picture-in-picture…
// @match        https://www.youtube.com/*
// @match        https://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.18
// @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
 * x to go forward by 5 seconds
 * z to go back by 5 seconds
 * shift + x to go forward by 1 second
 * shift + z to go back by 1 second
 * u to "undo" a time jump and go back to where you were just playing
 *
 * 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.
const MIN_TIME_BETWEEN_KEYPRESSES_MS = 70; // how long – in milliseconds – to wait before handling any repeated key presses. This is to prevent time scrubs from going too fast. Continuous events come as fast as every ~30ms if the repeat rate is set to the highest setting.
const SAVED_POSITIONS_TTL_SEC = 3600; // 1h by default, how long to keep track of saved positions by video ID (with [B], to be restored with [R]). This allows saving positions for multiple videos, forgetting them after 1h.

/****************************************************************************************************/

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) &&
        !([rect.left, rect.right, rect.top, rect.bottom].every(val => val === 0)); // some elements are returned with all-zero bounding client rect, they're not visible
}

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()) {
        // we need to call findRatingButtonShorts with both ID suffixes below, since it could be either of them.
        // this make the IDs either '#like-toggle-button' or '#like-button' for likes, for example. We keep only the first one found.
        const idSuffixes = ['toggle-button', 'button']; // added to '#like' or '#dislike'
        const getFirstButton = (innerSelector, idPrefix) => idSuffixes.map(suffix => findRatingButtonShorts(`ytd-toggle-button-renderer#${idPrefix}-${suffix}`, innerSelector))
            .filter(elt => elt !== null) // remove nulls
            .filter(isVisible)
            .find(_ => true) || null; // keep first object

        return {
            like: getFirstButton('#like-button button', 'like'),
            dislike: getFirstButton('#dislike-button button', 'dislike'),
        };
    } 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 likeButtonFromViewModel = document.querySelector('like-button-view-model button');
    const dislikeButtonFromViewModel = document.querySelector('dislike-button-view-model button');
    if (likeButtonFromViewModel && dislikeButtonFromViewModel) {
        return {like: likeButtonFromViewModel, dislike: dislikeButtonFromViewModel};
    }

    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 (const b of allButtons) {
            if (b.hasAttribute('aria-label')) {
                const hasAriaLabel = (text) => b.getAttribute('aria-label').toLowerCase().indexOf(text) !== -1;
                if (!dislike && hasAriaLabel('dislike')) {
                    dislike = b;
                } else if (!like && !hasAriaLabel('dislike') && hasAriaLabel('like')) { // we didn't match "dislike" so the only other way to match "like" would be the actual like button
                    like = b;
                }
                if (!dislike && hasAriaLabel('disabled')) {
                    dislike = b;
                }
            }
        }
        // last resort: by position
        if (!like && buttons.length > 4) {
            like = buttons[4];
        }
        if (!dislike && buttons.length > 5) {
            dislike = buttons[5];
        }
    }
    return {like, dislike};
}

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);
}

/** only one specific modifier is set **/
function onlyModifier(event, expected) {
    const modifiers = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'];
    for (const mod of modifiers) {
        const expectedValue = (mod == expected); // only true for the expected modifier
        if (event[mod] !== expectedValue) {
            return false;
        }
    }
    return true;
}

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();
    }
}

/** swallow keyboard event once we've handled it. */
function stopEvent(event) {
    event.stopPropagation();
    event.stopImmediatePropagation();
    event.preventDefault();
}

/** returns whether we are in an editable element, where text can be typed in (e.g. comment field, search box, chat input) */
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
}

/** returns whether this is a +/-1 or +/-5 shortcut */
function isOneSecFiveSecShortcut(e) {
    return (hasNoModifiers(e) || onlyModifier(e, 'shiftKey')) && (e.code === 'KeyZ' || e.code === 'KeyX');
}

/** keeps track of time passing in the video, enables going back if a jump was made */
function detectTimeJump() {
    const video = getVideoElement();
    const videoId = getVideoId();
    const player = document.getElementById('movie_player');
    if (!video || !videoId || !player) {
        return;
    }

    if (timeJumpMonitor.videoId !== videoId) { // video change (will trigger on first run, that's fine)
        resetTimeJumpMonitor();
    }
    if (timeJumpMonitor.videoId === null) { // not set yet, let's set it
        timeJumpMonitor.videoId = videoId;
    }
    const videoCurrentTime = video.currentTime;
    const previousCurrentTime = timeJumpMonitor.videoCurrentTime;
    timeJumpMonitor.videoCurrentTime = videoCurrentTime; // then update monitor record
    const timeDeltaSec = videoCurrentTime - previousCurrentTime;
    const detectionAllowed = Date.now() >= timeJumpMonitor.disableUntil; // don't detect jumps we triggered with "undo"
    if (detectionAllowed && (timeDeltaSec < 0 || timeDeltaSec >= 0.95 * player.getPlaybackRate())) { // jump detected! going backwards, or going forwards too fast.
        timeJumpMonitor.beforeJump = previousCurrentTime; // remember where we were just half a second ago
    }
}

/** saves video position for current video */
function saveCurrentPosition(videoId, videoCurrentTime) {
    savedPositions[videoId] = {videoTime: videoCurrentTime, savedAt: Date.now() };
    showNotification('Saved position: ' + formatTime(videoCurrentTime));
    cleanupOldSavedPositions();
}

/** restores saved video position for current video (we've already checked that we have a save) */
function restoreSavedPosition(video, videoId) {
    video.currentTime = savedPositions[videoId].videoTime;
    savedPositions[videoId].savedAt = Date.now(); // bump save timestamp so that we don't eventually lose it
    cleanupOldSavedPositions();
}

function cleanupOldSavedPositions() {
    const keys = Object.keys(savedPositions);
    const now = Date.now();
    var deleted = 0;
    for (var key of keys) {
        if (now - savedPositions[key].savedAt > SAVED_POSITIONS_TTL_SEC * 1000) {
            delete savedPositions[key];
            deleted++;
        }
    }
}

// local values that can change when various shortcuts are used
var savedPositions = {}; // keyed by videoId
var lastEventTimeMs = Date.now();
const defaultTimeJumpMonitor = {videoId: null, videoCurrentTime: 0, beforeJump: -1, disableUntil: -1};
var timeJumpMonitor = {};

function resetTimeJumpMonitor() {
    timeJumpMonitor = Object.assign({}, defaultTimeJumpMonitor);
}

setInterval(detectTimeJump, 500); // detect when the user jumps through the video to enable "undo jump"

addEventListener('keypress', function (e) { // handle keypress events
    const tag = e.target.tagName.toLowerCase();
    const nowMs = Date.now();
    if (isContentEditable(e.target) || tag == 'input' || tag == 'textarea' || // key press is in a text field, like the search field or typing a comment
        (nowMs - lastEventTimeMs) < MIN_TIME_BETWEEN_KEYPRESSES_MS) { // it hasn't been long enough since the last keypress
        return;
    }
    lastEventTimeMs = nowMs;

    const player = document.getElementById('movie_player');
    const buttons = findLikeDislikeButtons();
    const video = getVideoElement();
    const videoId = getVideoId();
    if (e.code == 'BracketLeft' && hasNoModifiers(e) && 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' && hasNoModifiers(e) && 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 && isOneSecFiveSecShortcut(e)) {
        const numSeconds = e.shiftKey ? 1 : 5;
        const multiplier = e.code === 'KeyZ' ? -1 : +1;
        applyDeltaSec(video, multiplier * numSeconds);
    } else if (video && e.code == 'KeyH' && hasNoModifiers(e)) {
        if (document.pictureInPictureElement !== null) { // already in PiP mode
            document.exitPictureInPicture();
        } else { // enable PiP
            if (video.hasAttribute('disablepictureinpicture')) { // just in case it's ever set, let's drop it.
                video.removeAttribute('disablepictureinpicture');
            }
            video.requestPictureInPicture();
        }
        stopEvent(e);
    } else if (video && e.code == 'KeyB' && hasNoModifiers(e)) { // back up current position
        saveCurrentPosition(videoId, video.currentTime);
        stopEvent(e);
    } else if (video && e.code == 'KeyR' && hasNoModifiers(e)) { // restore saved position
        if (videoId && savedPositions[videoId]) {
            restoreSavedPosition(video, videoId);
            stopEvent(e);
        } else {
            showNotification('No saved timestamp found');
        }
    } else if (video && e.code == 'KeyU' && hasNoModifiers(e) && timeJumpMonitor.beforeJump >= 0) { // restore position before a time jump
        video.currentTime = timeJumpMonitor.beforeJump;
        timeJumpMonitor.beforeJump = -1; // turn off
        timeJumpMonitor.disableUntil = Date.now() + 950; // allow a full monitor run before we check again, to avoid detecting this "undo" as a jump itself
    } else if (player && onlyModifier(e, 'shiftKey') && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
        const multiplier = (e.code === 'BracketLeft' ? -1 : +1);
        player.setPlaybackRate(player.getPlaybackRate() + multiplier * 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)
            stopEvent(e);
        } 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:
            var shouldStopEvent = true; // whether we've handled the keyboard event
            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);
            } else { // we didn't take *any* of the branches above
                shouldStopEvent = false;
            }

            if (shouldStopEvent) { // if we did handle the key press for one of the conditions above, then swallow the keyboard event.
                stopEvent(e);
            }
        }
    }
});