Youtube Seek Buttons

Add backward/forward buttons to the player bar

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Youtube Seek Buttons
// @match       https://www.youtube.com/*
// @grant       none
// @version     1.2
// @author      Duki
// @description Add backward/forward buttons to the player bar
// @license     Unlicense
// @namespace   https://greasyfork.org/users/1412820
// ==/UserScript==

const skips = [0, 1, 5, 10];

const style = document.createElement('style');
style.innerHTML = `
    #seek-button {
        margin-right: 10px;
        font-size: 30px;
        text-align: center;

        span {
            position: relative;
            top: -11px;
        }
    }

    #seek-button:hover {
        /* nested trick to select parent, see https://youtu.be/hiwvjsmD2iY?t=375 */
        :has(&) #seek-container { 
            display: flex;
        }
    }

    #seek-container:hover {
        display: flex;
    }

    #seek-container {
        position: absolute;
        transform: translate(-26%, -100%);
        z-index : 9999;
        display: none;
        flex-flow: column-reverse nowrap;
        padding: 3px;
        background-color: #000b;
        border-radius: 20px;

        button {
            width: 40px;
            aspect-ratio: 1;
            margin: 3px;
            font-weight: bold;
            color: white;
            background-color: #fff2;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            box-shadow: 0 0 0px 2px inset #fff3;

            &:hover {
                background-color: #fff4;
            }
        }

    }
`;
document.head.appendChild(style);

const insertAfter = ".ytp-time-display";


(function () {
    tryIni('.ytp-left-controls', '#seek-container', initialize, positionContainer);
})();


function tryIni(mustExist, shouldExist, iniCallback, repeatCallback) {
    setInterval(() => {
        if (document.querySelector(mustExist)) {
            if (!document.querySelector(shouldExist)) {
                iniCallback();
            } else {
                repeatCallback();
            }
        }
    }, 2000);
}


function initialize() {
    addContainer();
    skips.forEach(skipAmount => {
        addSkipBtn(skipAmount);
    });

    window.addEventListener('resize', positionContainer);
}


function seekVideo(skipAmount) {
    const video = document.querySelector("video");
    if (skipAmount == 0) {
        const isBackward = Object.is(skipAmount, -0); // "0 === -0" is true, "Object.is(0, -0)" isn't
        skipAmount = isBackward ? -1 * getFrameSkipAmount() : getFrameSkipAmount();
    }
    video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime) + skipAmount);
}


function getFrameSkipAmount() {
    const videoOptionItems = document.querySelectorAll('.ytp-menuitem-content');

    let framerate = 30;
    for (const item of videoOptionItems) {
        const match = item.textContent.match(/p(\d+)/);
        if (match) {
            framerate = parseInt(match[ 1 ]);
        }
    }

    return 1000 / framerate / 1000;
}


function addSkipBtn(skipAmount) {
    const container = document.getElementById('seek-container');
    const skipContainer = document.createElement('div');

    const backward = document.createElement('button');
    backward.classList.add("backward");
    backward.textContent = `-${skipAmount}`
    backward.addEventListener('click', () => {
        seekVideo(-skipAmount);
    })
    skipContainer.appendChild(backward);

    const forward = document.createElement('button');
    forward.classList.add("forward");
    forward.textContent = `+${skipAmount}`
    forward.addEventListener('click', () => {
        seekVideo(skipAmount);
    })
    skipContainer.appendChild(forward);

    container.appendChild(skipContainer);
}


function addContainer() {
    const button = document.createElement('div');
    button.id = 'seek-button';
    button.classList.add("ytp-play-button", "ytp-button");

    const span = document.createElement('span');
    span.textContent = "⤺";
    button.appendChild(span);

    document.querySelector(insertAfter).after(button);

    const container = document.createElement('div');
    container.id = 'seek-container';

    document.getElementById('movie_player').appendChild(container);

    positionContainer();
}


function positionContainer() {
    const button = document.getElementById('seek-button');
    const container = document.getElementById('seek-container');

    const isFullscreen = document.querySelector('ytd-app').hasAttribute('fullscreen');
    const isTheater = document.querySelector('.ytp-size-button').dataset.tooltipTitle.includes('Default');

    const topBarOffset = isFullscreen ? 0 : document.getElementById('container').offsetHeight;
    const sideBarOffset = isTheater || isFullscreen ? 0 : 16;

    const hoverFix = !isTheater && !isFullscreen ? -5 : 5;
    container.style.top = getAbsolutePosition(button).top - topBarOffset + hoverFix + 'px';
    container.style.left = getAbsolutePosition(button).left - sideBarOffset + 'px';
}


function getAbsolutePosition(element) {
    const rect = element.getBoundingClientRect();

    return {
        top: rect.top + window.scrollY,
        left: rect.left + window.scrollX,
        bottom: rect.bottom + window.scrollY,
        right: rect.right + window.scrollX
    };
}