Better Keyboard Shortcuts for SharePoint

Simpler keyboard shorcuts with visual indicators for Microsoft SharePoint videos

// ==UserScript==
// @name         Better Keyboard Shortcuts for SharePoint
// @namespace    http://tampermonkey.net/
// @version      1.3.0
// @description  Simpler keyboard shorcuts with visual indicators for Microsoft 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 topMiddleOverlay = document.createElement('div');
        topMiddleOverlay.style.position = 'absolute';
        topMiddleOverlay.style.top = '10%';
        topMiddleOverlay.style.left = '0';
        topMiddleOverlay.style.right = '0';
        topMiddleOverlay.style.margin = 'auto';
        topMiddleOverlay.style.padding = '7px 11px';
        topMiddleOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        topMiddleOverlay.style.borderRadius = '5px';
        topMiddleOverlay.style.pointerEvents = 'none';
        topMiddleOverlay.style.opacity = '0';
        topMiddleOverlay.style.transition = 'opacity 0.015s ease';
        topMiddleOverlay.style.zIndex = '9999';
        topMiddleOverlay.style.userSelect = 'none';
        topMiddleOverlay.style.display = 'flex';
        topMiddleOverlay.style.flexDirection = 'column';
        topMiddleOverlay.style.alignItems = 'center';
        topMiddleOverlay.style.justifyContent = 'center';
        topMiddleOverlay.style.gap = '0.2em';
        topMiddleOverlay.style.width = '50px';
        topMiddleOverlay.style.minHeight = '30px';
        topMiddleOverlay.style.textAlign = 'center';
        videoRoot.appendChild(topMiddleOverlay);

        // 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';
        centerOverlay.style.color = 'rgba(255,255,255,0.85)';
        videoRoot.appendChild(centerOverlay);

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

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

        // 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 hideTimeoutTopMiddleOverlay;
        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.75s ease, height 0.75s ease, font-size 0.75s ease, opacity 0.75s 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';
            }, 50);

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


        // 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 triggerTopMiddleText(action) {
            if (action == 'volume') {
                const volumePercent = video.volume * 100;
                if (volumePercent >= 1 || video.volume == 0)
                    topMiddleText.textContent = `${Math.round(volumePercent)}%`;
                else
                    topMiddleText.textContent = `${volumePercent.toFixed(1)}%`;

            } else if (action == 'speed') {
                const speed = video.playbackRate;
                topMiddleText.textContent = (speed % 1 === 0) ? `${speed}x` : `${speed.toFixed(2)}x`;
                if (topMiddleText.textContent.endsWith('0x'))
                    topMiddleText.textContent = `${speed.toFixed(1)}x`;
            }

            topMiddleOverlay.style.opacity = '1';
            clearTimeout(hideTimeoutTopMiddleOverlay);
            hideTimeoutTopMiddleOverlay = setTimeout(() => { topMiddleOverlay.style.opacity = '0'; }, 500);
        }

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

            e.preventDefault();

            // Home to jump to start
            if (e.code === 'Home')
                video.currentTime = 0;

            // End to jump to end
            else if (e.code === 'End')
                video.currentTime = video.duration;

            // Period to skip to next frame
            else if (e.code === 'Period' && !e.shiftKey)
                video.currentTime = Math.min(video.duration, video.currentTime + (1/30));

            // Comma to skip to previous frame
            else if (e.code === 'Comma' && !e.shiftKey)
                video.currentTime = Math.max(0, video.currentTime - (1/30));

            // > to speed up
            else if (e.key === '>') {
                document.querySelector('[aria-description*="Alt + X"]').click();
                const items = [...document.querySelectorAll('[role="menuitemradio"]')];
                const currentIndex = items.findIndex(item => item.getAttribute('aria-checked') === 'true');

                if (currentIndex > 0)
                    items[currentIndex - 1].click();
                else
                    document.querySelector('[aria-description*="Alt + X"]').click();
                triggerTopMiddleText('speed');
            }

            // < to slow down
            else if (e.key === '<') {
                document.querySelector('[aria-description*="Alt + Z"]').click();
                const items = [...document.querySelectorAll('[role="menuitemradio"]')];
                const currentIndex = items.findIndex(item => item.getAttribute('aria-checked') === 'true');

                if (currentIndex < items.length - 1)
                    items[currentIndex + 1].click();
                else
                    document.querySelector('[aria-description*="Alt + Z"]').click();
                triggerTopMiddleText('speed');
            }

            // 0–9 to skip to 0%–90% of video
            else if (/^Digit[0-9]$/.test(e.code))
                video.currentTime = (video.duration * (parseInt(e.code.replace('Digit', ''), 10))) / 10;

            // → to skip forward
            else 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));
                }
                triggerTopMiddleText('volume');
                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));
                }
                triggerTopMiddleText('volume');

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

            // / to go to search box
            } else if (e.key === '/') {
                const searchInput = document.querySelector('input[role="combobox"][type="search"][placeholder="Search"]');
                searchInput.focus();
                searchInput.select();
                return;

            // Trigger SharePoint's keyboard shortcuts / advanced features
            } 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"]'),
                    KeyK: document.querySelector('[aria-description*="Alt + K"]'),
                    KeyJ: document.querySelector('[aria-description*="Alt + J"]'),
                    KeyL: document.querySelector('[aria-description*="Alt + L"]'),
                    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 (keys.hasOwnProperty(e.code))
                    keys[e.code].click();


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

                } else if (e.code === 'KeyC') {
                    const items = [...document.querySelectorAll('[role="menuitemradio"]')];
                    const menuitem = document.querySelector('button[role="menuitem"][aria-label="Captions"]');
                    const currentIndex = items.findIndex(item => item.getAttribute('aria-checked') === 'true');

                    if (currentIndex >= items.length - 1) {
                        items[0].click();
                        menuitem.setAttribute('aria-checked', 'false');

                    } else if (currentIndex < items.length - 1) {
                        items[currentIndex + 1].click();
                        menuitem.setAttribute('aria-checked', 'true');
                    }

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

            document.querySelector(".fluent-critical-ui-container").focus();
        });

    });
})();