Playback Shortcuts

Adds playback shortcuts to video players. ('Ctrl + >'/'Ctrl + <' to change playback rate, 'Ctrl + .' to enter PiP)

// ==UserScript==
// @name         Playback Shortcuts
// @namespace    endorh
// @version      1.1
// @description  Adds playback shortcuts to video players. ('Ctrl + >'/'Ctrl + <' to change playback rate, 'Ctrl + .' to enter PiP)
// @author       endorh
// @match        https://*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @license      MIT
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';



    // == UserSettings ==

    // Hotkeys
    let enterPiPHotkey = (e) => e.key == '.' && e.ctrlKey
    let decreasePlaybackRateHotkey = (e) => e.key == '<' && e.ctrlKey
    let increasePlaybackRateHotkey = (e) => e.key == '>' && e.ctrlKey

    // Playback rate steps (must be sorted!)
    let playbackRates = [
        0.01, 0.025, 0.05, 0.1, 0.15, 0.20,
        0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2,
        2.5, 3, 3.5, 4, 5, 7.5, 10, 15, 20
    ] // Extra playback steps
    // let playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] // Classic YouTube rate steps

    // Playback overlay fade timeout in ms
    let fadeTimeout = 350 // ms

    // CSS style of the overlay. The overlay consists of three divs:
    // - outer container (sibling to the video element)
    //     It's applied the `globalPlaybackRateOverlayFadeOut` class after `fadeTimeout` ms
    //     have happened since the last playback rate change
    // - container (child to the outer container)
    //     Has the .globalPlaybackRateOverlayContainer class
    //     Its `position` should be `absolute`, as its actual position rectangle is updated
    //     on every playback rate change to match that of the video.
    //     Should be entirely transparent, not interactable and have a high z-index.
    // - overlay (child to the container)
    //     Its content is set to `${video.playbackRate}x` after each playback rate change.
    //     Can be centered with respect to its parent, which should match the dimensions of
    //     the video.
    //     Should be semitransparent to not disturb the video.
    GM_addStyle(`
        /* Container style */
        .globalPlaybackRateOverlayContainer {
            position: absolute;
            transition: opacity 0.05s;
            pointer-events: none;
            z-index: 2147483647;
        }

        /* Container style after fadeTimeout */
        .globalPlaybackRateOverlayFadeOut {
            transition: opacity 0.35s;
            opacity: 0%;
        }

        /* Overlay div style */
        .globalPlaybackRateOverlay {
            position: absolute;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);

            padding: 0.5em;
            font-size: 24px;
            border-radius: 0.25em;

            color: #FEFEFEFE !important;
            background-color: #000000A0 !important;
        }
    `)

    // Set to true to enable console messages
    let debug = false

    // Set to true to enable console warnings
    let warn = debug || true

    // Set to false if a website is using modification observers near the video element
    // to detect added overlays, breaking the video.
    let useOverlay = true



    // == Script ==
    // Object to preserve state across events
    let state = {};

    // Add the keyboard hook
    document.addEventListener('keydown', function(e) {
        if (enterPiPHotkey(e)) {
            if (enterPiP()) {
                if (debug) console.info("Entered PiP")
            }
        } else if (decreasePlaybackRateHotkey(e)) {
            if (modifyPlaybackRate(false)) {
                if (debug) console.info("Decreased playback rate")
                e.preventDefault()
            }
        } else if (increasePlaybackRateHotkey(e)) {
            if (modifyPlaybackRate(true)) {
                if (debug) console.info("Increased playback rate")
                e.preventDefault()
            }
        }
    })

    function findVideoElement() {
        // Find video element
        let videos = [...document.getElementsByTagName('video')]
        var video = null
        if (videos.length == 0) {
            if (debug) console.info("No video found.")
            return null
        } else if (videos.length == 1) {
            video = videos[0]
        } else {
            if (warn) console.warn("Multiple videos found, using only the first video found")
            if (debug) console.log(videos);
            video = videos[0]
        }
        return video
    }

    // Enter PiP (Picture-in-Picture)
    function enterPiP() {
        let video = findVideoElement()
        if (video == null) return false
        let doc = video.ownerDocument
        // Toggle Picture-in-Picture
        if (doc.pictureInPictureElement != video) {
            if (doc.pictureInPictureElement) {
                doc.exitPictureInPicture()
            }
            video.requestPictureInPicture()
        } else doc.exitPictureInPicture()
        return true
    }

    // Modify the playback
    function modifyPlaybackRate(faster) {
        let video = findVideoElement()
        if (video == null) return false

        // Current playback rate
        let pr = video.playbackRate

        // Find target playback (comparisons use a 1e-7 delta to avoid rounding nonsense)
        let target = faster? playbackRates.find(r => r > pr + 1e-7) : playbackRates.findLast(r => r < pr - 1e-7)
        if (debug) console.info("Changing playbackRate: " + pr + " -> " + target)

        // Set playback rate
        video.playbackRate = target
        if (debug) console.log("Modified playbackRate: " + video.playbackRate)

        // Check changed playback rate
        if (warn && video.playbackRate != target) {
            console.warn("Could not modify playbackRate!\nTarget: " + target + "\nActual: " + video.playbackRate)
        }

        // Display overlay with the final playback rate
        if (useOverlay) updateOverlay(video, video.playbackRate);
        return true
    }

    // Display an overlay with the updated playback rate
    function updateOverlay(v, rate) {
        // Reuse previous overlay
        var container = null
        if (state.overlay !== undefined) {
            if (state.timeout !== undefined) clearTimeout(state.timeout)
            container = state.overlay
        } else container = document.createElement('div')

        // Inline positions and rate value
        let parent = v.parentElement
        let r = v.getBoundingClientRect()
        let p = parent.getBoundingClientRect()
        let html = `
        <div class="globalPlaybackRateOverlayContainer" style="
            left: ${r.left - p.left}px;
            right: ${r.right - p.left}px;
            top: ${r.top - p.top}px;
            bottom: ${r.bottom - p.top}px;
            width: ${r.width}px;
            height: ${r.height}px;
        ">
            <div class="globalPlaybackRateOverlay">
                ${rate}x
            </div>
        </div>
        `
        container.innerHTML = html

        // Remove fade out
        container.classList.remove("globalPlaybackRateOverlayFadeOut")

        // Add overlay
        if (state.overlay !== undefined && state.overlay.parentElement != v.parentElement) {
            state.overlay.parentElement.removeChild(state.overlay)
            state.overlay = undefined
        }
        if (state.overlay === undefined) {
            v.parentElement.appendChild(container)
            state.overlay = container
        }

        // Set timeout for the fade out animation
        state.timeout = setTimeout(function() {
            container.classList.add("globalPlaybackRateOverlayFadeOut")
            state.timeout = undefined
        }, fadeTimeout);
    }
})();