Better Keyboard Shortcuts for Microsoft Stream/SharePoint

Simpler keyboard shorcuts with visual indicators for Microsoft Stream/SharePoint videos

// ==UserScript==
// @name         Better Keyboard Shortcuts for Microsoft Stream/SharePoint
// @namespace    http://tampermonkey.net/
// @version      1.2.0
// @description  Simpler keyboard shorcuts with visual indicators for Microsoft Stream/SharePoint videos
// @author       kazcfz
// @include      https://*.sharepoint.com/*
// @icon         https://res-1.cdn.office.net/shellux/stream_24x.12dba766a9c30382b781c971070dc87c.svg
// @grant        none
// @license      MIT
// ==/UserScript==

/* eslint curly: "off" */

(function () {
    'use strict';

    // Wait until .oneplayer-root exists in the DOM, then run callback with it
    function waitForOnePlayerRoot(callback) {
        const root = document.querySelector('.oneplayer-root') || document.querySelector('.OnePlayer-container');
        if (root) {
            callback(root);
            return;
        }
        const docObserver = new MutationObserver((mutations, obs) => {
            const el = document.querySelector('.oneplayer-root') || document.querySelector('.OnePlayer-container');
            if (el) {
                obs.disconnect();
                callback(el);
            }
        });
        docObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
    }

    waitForOnePlayerRoot(videoRoot => {
        if (getComputedStyle(videoRoot).position === 'static')
            videoRoot.style.position = 'relative';

        // Overlay to indicate Skip
        const skipOverlay = document.createElement('div');
        skipOverlay.style.position = 'absolute';
        skipOverlay.style.top = '50%';
        skipOverlay.style.transform = 'translateY(-50%)';
        skipOverlay.style.padding = '15px 15px';
        skipOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.9)';
        skipOverlay.style.borderRadius = '50%';
        skipOverlay.style.pointerEvents = 'none';
        skipOverlay.style.opacity = '0';
        skipOverlay.style.transition = 'opacity 0.1s ease';
        skipOverlay.style.zIndex = '9999';
        skipOverlay.style.userSelect = 'none';
        skipOverlay.style.display = 'flex';
        skipOverlay.style.flexDirection = 'column';
        skipOverlay.style.alignItems = 'center';
        skipOverlay.style.justifyContent = 'center';
        skipOverlay.style.gap = '0.2em';
        skipOverlay.style.minWidth = '75px';
        skipOverlay.style.minHeight = '75px';
        skipOverlay.style.textAlign = 'center';
        videoRoot.appendChild(skipOverlay);

        // Overlay to indicate Volume
        const volumeOverlay = document.createElement('div');
        volumeOverlay.style.position = 'absolute';
        volumeOverlay.style.top = '10%';
        volumeOverlay.style.left = '0';
        volumeOverlay.style.right = '0';
        volumeOverlay.style.margin = 'auto';
        volumeOverlay.style.padding = '7px 11px';
        volumeOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        volumeOverlay.style.borderRadius = '5px';
        volumeOverlay.style.pointerEvents = 'none';
        volumeOverlay.style.opacity = '0';
        volumeOverlay.style.transition = 'opacity 0.015s ease';
        volumeOverlay.style.zIndex = '9999';
        volumeOverlay.style.userSelect = 'none';
        volumeOverlay.style.display = 'flex';
        volumeOverlay.style.flexDirection = 'column';
        volumeOverlay.style.alignItems = 'center';
        volumeOverlay.style.justifyContent = 'center';
        volumeOverlay.style.gap = '0.2em';
        volumeOverlay.style.width = '50px';
        volumeOverlay.style.minHeight = '30px';
        volumeOverlay.style.textAlign = 'center';
        videoRoot.appendChild(volumeOverlay);

        // Overlay to indicate Play/Pause
        const centerOverlay = document.createElement('div');
        centerOverlay.style.position = 'absolute';
        centerOverlay.style.top = '50%';
        centerOverlay.style.left = '50%';
        centerOverlay.style.right = 'auto';
        centerOverlay.style.margin = '0';
        centerOverlay.style.transform = 'translate(-50%, -50%)';
        centerOverlay.style.padding = '15px';
        centerOverlay.style.fontVariantEmoji = 'text';
        centerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        centerOverlay.style.borderRadius = '50%';
        centerOverlay.style.pointerEvents = 'none';
        centerOverlay.style.opacity = '0';
        centerOverlay.style.zIndex = '9999';
        centerOverlay.style.userSelect = 'none';
        centerOverlay.style.display = 'flex';
        centerOverlay.style.flexDirection = 'column';
        centerOverlay.style.alignItems = 'center';
        centerOverlay.style.justifyContent = 'center';
        centerOverlay.style.textAlign = 'center';
        videoRoot.appendChild(centerOverlay);

        const centerSymbol = document.createElement('span');
        centerSymbol.style.opacity = '1';
        centerOverlay.appendChild(centerSymbol);

        // Overlay text for current volume
        const volumeText = document.createElement('div');
        volumeText.style.fontSize = '17px';
        volumeText.style.fontWeight = '500';
        volumeText.style.color = 'rgba(255,255,255,0.85)';
        volumeText.style.userSelect = 'none';
        volumeOverlay.appendChild(volumeText);

        // Overlay container for left/right triangles
        const trianglesContainer = document.createElement('div');
        trianglesContainer.style.display = 'flex';
        trianglesContainer.style.gap = '10px';
        skipOverlay.appendChild(trianglesContainer);

        // Add triangles into its container
        const triangles = [];
        for (let i = 0; i < 3; i++) {
            const tri = document.createElement('span');
            tri.textContent = '▶';
            tri.style.fontSize = '13px';
            tri.style.fontWeight = 'bold';
            trianglesContainer.appendChild(tri);
            triangles.push(tri);
        }

        // Overlay text for seconds skipped
        const secondsText = document.createElement('div');
        secondsText.style.fontSize = '14px';
        secondsText.style.fontWeight = 'normal';
        secondsText.style.color = 'rgba(255,255,255,0.85)';
        secondsText.style.userSelect = 'none';
        skipOverlay.appendChild(secondsText);

        let hideTimeoutSkipOverlay;
        let hideTimeoutVolumeOverlay;
        let hideTimeoutCenterOverlay;
        let animTimeouts = [];

        // Displays overlay triangle animation and seconds skipped
        const secondsToSkip = 5;
        function showAnimatedTriangles(side) {
            // Clear animation timers
            clearTimeout(hideTimeoutSkipOverlay);
            animTimeouts.forEach(t => clearTimeout(t));
            animTimeouts = [];

            const isLeft = side === 'left';
            const char = isLeft ? '◀' : '▶';
            const order = isLeft ? [2, 1, 0] : [0, 1, 2];

            triangles.forEach(t => {
                t.textContent = char;
                t.style.transition = 'none';
                t.style.color = 'rgba(255,255,255,0.3)';
            });

            // Force style flush to apply the color immediately before re-enabling transitions
            void skipOverlay.offsetHeight;
            triangles.forEach(t => { t.style.transition = 'color 0.3s ease'; });

            secondsText.textContent = `${secondsToSkip} seconds`;

            skipOverlay.style.opacity = '1';
            skipOverlay.style.left = isLeft ? '10%' : 'auto';
            skipOverlay.style.right = isLeft ? 'auto' : '10%';
            skipOverlay.style.textAlign = 'center';

            triangles[order[0]].style.color = 'rgba(255,255,255,0.75)';

            const interval = 200;

            for (let step = 2; step <= 3; step++) {
                animTimeouts.push(setTimeout(() => {
                    if (step === 2) {
                        triangles[order[0]].style.color = 'rgba(255,255,255,0.5)';
                        triangles[order[1]].style.color = 'rgba(255,255,255,0.75)';
                    }
                    else if (step === 3) {
                        triangles[order[0]].style.color = 'rgba(255,255,255,0.3)';
                        triangles[order[1]].style.color = 'rgba(255,255,255,0.5)';
                        triangles[order[2]].style.color = 'rgba(255,255,255,0.75)';
                    }
                }, (step - 1) * interval));
            }

            hideTimeoutSkipOverlay = setTimeout(() => { skipOverlay.style.opacity = '0'; }, 3.3 * interval);
        }

        // Checks that the video player is focused
        function isInOnePlayerRoot(element) {
            while (element) {
                if (element === videoRoot)
                    return true;
                element = element.parentElement;
            }
            return false;
        }

        let video = null;

        function setupVideo(v) {
            if (!v || video === v)
                return;
            video = v;
        }

        // Initial attempt to find video
        setupVideo(videoRoot.querySelector('video'));

        // Observe videoRoot subtree for added/removed video
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations)
                for (const node of mutation.addedNodes)
                    if (node.nodeType === 1)
                        if (node.tagName === 'VIDEO')
                            setupVideo(node);
                        else {
                            const v = node.querySelector && node.querySelector('video');
                            if (v)
                                setupVideo(v);
                        }
        });

        observer.observe(videoRoot, { childList: true, subtree: true });


        function triggerCenterSymbol(symbol) {
            clearTimeout(hideTimeoutCenterOverlay);

            centerOverlay.style.width = '75px';
            centerOverlay.style.height = '75px';
            centerOverlay.style.transition = 'opacity 0.1s ease';
            centerOverlay.style.opacity = '1';

            hideTimeoutCenterOverlay = setTimeout(() => {
                centerOverlay.style.transition = 'width 0.6s ease, height 0.6s ease, font-size 0.6s ease, opacity 0.6s ease';
                centerOverlay.style.width = `${parseFloat(getComputedStyle(centerOverlay).width) + 25}px`;
                centerOverlay.style.height = `${parseFloat(getComputedStyle(centerOverlay).height) + 25}px`;
                centerOverlay.style.fontSize = `${parseFloat(getComputedStyle(centerOverlay).fontSize) + 22.5}px`;
                centerOverlay.style.opacity = '0';
            }, 60);

            centerSymbol.textContent = symbol;
            if (symbol === '🔊' || symbol === '🔇') {
                centerSymbol.style.marginBottom = '9px';
                centerSymbol.style.marginLeft = '5px';
                centerOverlay.style.fontSize = '75px';
            } else if (symbol === '🔉') {
                centerSymbol.style.marginBottom = '9px';
                centerSymbol.style.marginLeft = '0px';
                centerOverlay.style.fontSize = '75px';
            } else if (symbol === '▶') {
                centerSymbol.style.marginBottom = '7px';
                centerSymbol.style.marginLeft = '12px';
                centerOverlay.style.fontSize = '55px';
            } else if (symbol === '⏸') {
                centerSymbol.style.marginBottom = '17px';
                centerSymbol.style.marginLeft = '4px';
                centerOverlay.style.fontSize = '68px';
            }
        }


        // Math time: MS Stream/SharePoint handles volume steps exponentially (cubically) rather than linearly
        // The sequence is (steps of 0.1)^3, counting down from 1 to 0:
        // volume (base 𝑛) = (1 − 0.1 × 𝑛)^3, where 𝑛 = 0, 1, 2, ..., 10.
        // Alternatively, could just keep it to linear volume steps
        let currentVolumeStepCount = 0;
        function displayVolume() {
            const volumePercent = video.volume * 100;
            if (volumePercent >= 1 || video.volume == 0)
                volumeText.textContent = `${Math.round(volumePercent)}%`;
            else
                volumeText.textContent = `${volumePercent.toFixed(1)}%`;

            volumeOverlay.style.opacity = '1';
            clearTimeout(hideTimeoutVolumeOverlay);
            hideTimeoutVolumeOverlay = setTimeout(() => { volumeOverlay.style.opacity = '0'; }, 600);
        }

        document.addEventListener('keydown', e => {
            if (!video)
                return;
            if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey)
                return;
            if (document.activeElement && !isInOnePlayerRoot(document.activeElement))
                return;

            e.preventDefault();

            // → to skip forward
            if (e.code === 'ArrowRight') {
                video.currentTime = Math.min(video.duration, video.currentTime + secondsToSkip);
                showAnimatedTriangles('right');

            // ← to skip backward
            } else if (e.code === 'ArrowLeft') {
                video.currentTime = Math.max(0, video.currentTime - secondsToSkip);
                showAnimatedTriangles('left');

            // ↑ to increase volume
            } else if (e.code === 'ArrowUp') {
                if (currentVolumeStepCount > 0) {
                    currentVolumeStepCount--;
                    video.volume = Math.pow(1 - 0.1 * currentVolumeStepCount, 3);
                    //video.volume = Math.max(0, Math.min(1, Math.round((video.volume + 0.1) * 100) / 100));
                }
                displayVolume();
                triggerCenterSymbol('🔊');

            // ↓ to decrease volume
            } else if (e.code === 'ArrowDown') {
                if (currentVolumeStepCount < 10) {
                    currentVolumeStepCount++;
                    video.volume = Math.pow(1 - 0.1 * currentVolumeStepCount, 3);
                    //video.volume = Math.max(0, Math.min(1, Math.round((video.volume - 0.1) * 100) / 100));
                }
                displayVolume();

                if (video.volume == 0)
                    triggerCenterSymbol('🔇');
                else
                    triggerCenterSymbol('🔉');

            } else {
                // Adapted from [Sharepoint Keyboard Shortcuts] by [CyrilSLi], MIT License
                // https://greatest.deepsurf.us/en/scripts/524190-sharepoint-keyboard-shortcuts
                const keys = {
                    Space: document.querySelector('[aria-description*="Alt + K"]'),
                    KeyF: document.querySelector('[aria-description*="Alt + Enter"]'),
                    KeyM: document.querySelector('[aria-description*="Alt + M"]'),
                    KeyC: document.querySelector('[aria-description*="Alt + C"]'),
                    KeyA: document.querySelector('[aria-description*="Alt + A"]'),
                }

                if (e.code === 'Space') {
                    if (video.paused)
                        triggerCenterSymbol('▶');
                    else
                        triggerCenterSymbol('⏸');

                } else if (e.code === 'KeyM')
                    if (video.muted)
                        triggerCenterSymbol('🔊');
                    else
                        triggerCenterSymbol('🔇');

                if (keys.hasOwnProperty(e.code))
                    keys[e.code].click();
            }
        });

    });
})();