Greasy Fork is available in English.

Twitch Seeking

Keyboard shortcuts to seek more easily in Twitch VODs

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Twitch Seeking
// @namespace    https://greasyfork.org/users/45933
// @version      0.5.3
// @author       Fizzfaldt
// @description  Keyboard shortcuts to seek more easily in Twitch VODs
// @run-at       document-idle
// @grant        none
// @noframes
// @match        *://*.twitch.tv/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitch.tv
// @license MIT
// ==/UserScript==

// Useful things:
//&& e.altKey
//&& e.shiftKey
//&& e.ctrlKey

var player;
var seek;
var enabled = false;
var seekPopup;
var movePopup;

// --- Popup Timing Variables ---
// Duration (in milliseconds) that the popup remains fully visible before fading out
var POPUP_DURATION = 1000;
// Fade-out duration in milliseconds
var FADEOUT_DURATION = 250;

// Helper: Get the current container (full screen element if active, else document.body)
function getPopupContainer() {
    return document.fullscreenElement || document.body;
}

// Helper: If popup is not in the current container, reattach it.
function reattachPopup(popup) {
    const container = getPopupContainer();
    if (popup.parentElement !== container) {
        popup.remove();
        container.appendChild(popup);
    }
}

// Listen for fullscreen changes to reattach popups if needed.
document.addEventListener("fullscreenchange", () => {
    if (seekPopup) reattachPopup(seekPopup);
    if (movePopup) reattachPopup(movePopup);
});

// by chatgpt
function formatTime(seconds) {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const secs = (seconds % 60).toFixed(1);
    return `${hours.toString().padStart(2, "0")}:${minutes
        .toString()
        .padStart(2, "0")}:${secs.padStart(4, "0")}`;
}

// by chatgpt
function createSeekPopup() {
    seekPopup = document.createElement("div");
    seekPopup.style.position = "fixed";
    seekPopup.style.top = "10px";
    seekPopup.style.left = "50%";
    seekPopup.style.transform = "translateX(-50%)";
    seekPopup.style.padding = "10px 20px";
    seekPopup.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
    seekPopup.style.color = "white";
    seekPopup.style.fontSize = "18px";
    seekPopup.style.borderRadius = "5px";
    seekPopup.style.zIndex = "10000";
    // No transition for fade-in; will set fade-out later.
    seekPopup.style.transition = "none";
    seekPopup.style.opacity = "0";
    getPopupContainer().appendChild(seekPopup);
}

// by chatgpt
function createMovePopup() {
    movePopup = document.createElement("div");
    movePopup.style.position = "fixed";
    movePopup.style.top = "50px";
    movePopup.style.left = "50%";
    movePopup.style.transform = "translateX(-50%)";
    movePopup.style.padding = "10px 20px";
    movePopup.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
    movePopup.style.color = "white";
    movePopup.style.fontSize = "18px";
    movePopup.style.borderRadius = "5px";
    movePopup.style.zIndex = "10000";
    movePopup.style.transition = "none";
    movePopup.style.opacity = "0";
    getPopupContainer().appendChild(movePopup);
}

function showChangeSeekPopup() {
    if (!seekPopup) {
        createSeekPopup();
    } else {
        reattachPopup(seekPopup);
    }
    seekPopup.textContent = `Seek: ${seek}s`;
    // Instant fade-in:
    seekPopup.style.transition = "none";
    seekPopup.style.opacity = "1";
    setTimeout(() => {
        // Fade out using configured duration:
        seekPopup.style.transition = `opacity ${FADEOUT_DURATION}ms ease-out`;
        seekPopup.style.opacity = "0";
    }, POPUP_DURATION);
}

function showSeekPopup(amount, direction, targetTime) {
    if (!seekPopup) {
        createSeekPopup();
    } else {
        reattachPopup(seekPopup);
    }
    const symbol = direction === "forward" ? ">>" : "<<";
    const formattedTime = formatTime(targetTime);
    seekPopup.textContent = `${symbol} ${amount}s to ${formattedTime}`;
    // Instant fade-in:
    seekPopup.style.transition = "none";
    seekPopup.style.opacity = "1";
    setTimeout(() => {
        // Fade out:
        seekPopup.style.transition = `opacity ${FADEOUT_DURATION}ms ease-out`;
        seekPopup.style.opacity = "0";
    }, POPUP_DURATION);
}

function showMovePopup(targetTime, percentage) {
    if (!movePopup) {
        createMovePopup();
    } else {
        reattachPopup(movePopup);
    }
    const formattedTime = formatTime(targetTime);
    movePopup.textContent = `${formattedTime} (${percentage}%)`;
    // Instant fade-in:
    movePopup.style.transition = "none";
    movePopup.style.opacity = "1";
    setTimeout(() => {
        // Fade out:
        movePopup.style.transition = `opacity ${FADEOUT_DURATION}ms ease-out`;
        movePopup.style.opacity = "0";
    }, POPUP_DURATION);
}

function ensure_player() {
    'use strict';
    if (!player) {
        console.log("Finding video player for seeking");
        player = document.querySelector('video');
        if (!player) {
            alert("Failed to initialize video player for seeking");
            return;
        }
    }
    if (!player) {
        alert("Video player lost after initializations");
        return;
    }
}

function ensure_seeking() {
    'use strict';
    ensure_player();
    if (typeof(player.currentTime) !== 'number') {
        alert("Cannot find current time in player");
        return;
    }
}

function ensure_percent_seeking() {
    'use strict';
    ensure_seeking();
    if (typeof(player.duration) !== 'number') {
        alert("Cannot find duration in player");
        return;
    }
}

function increase_seek() {
    'use strict';
    ensure_player();
    const increases = {
           1 :    5,
           5 :   10,
          10 :   30,
          30 :   60,
          60 :  300,
         300 :  600,
         600 : 1800,
        1800 : 3600,
        3600 : 3600,
    };
    if (seek in increases) {
        seek = increases[seek];
    } else {
        alert("Cannot find " + seek + " in increase dictionary");
        seek = 60;
    }
    console.log("Seek amount is now " + seek);
    showChangeSeekPopup();
}

function decrease_seek() {
    'use strict';
    ensure_player();
    const decreases = {
           1 :    1,
           5 :    1,
          10 :    5,
          30 :   10,
          60 :   30,
         300 :   60,
         600 :  300,
        1800 :  600,
        3600 : 1800,
    };
    if (seek in decreases) {
        seek = decreases[seek];
    } else {
        alert("Cannot find " + seek + " in decrease dictionary");
        seek = 60;
    }
    console.log("Seek amount is now " + seek);
    showChangeSeekPopup();
}

function seek_forwards() {
    'use strict';
    ensure_seeking();
    const before = player.currentTime;
    const targetTime = Math.min(before + seek, player.duration - 1.0);
    console.log(`Seeking ${seek} seconds forward from ${formatTime(before)} to ${formatTime(targetTime)}`);
    player.currentTime = targetTime;
    showSeekPopup(seek, "forward", targetTime);
}

function seek_backwards() {
    'use strict';
    ensure_seeking();
    const before = player.currentTime;
    const targetTime = Math.max(before - seek, 0.001); // Hack cause it doesn't like 0 for some reason
    console.log(`Seeking ${seek} seconds backward from ${formatTime(before)} to ${formatTime(targetTime)}`);
    player.currentTime = targetTime;
    showSeekPopup(seek, "backward", targetTime);
}

function seek_percent(n) {
    'use strict';
    ensure_percent_seeking();
    const targetTime = Math.max(player.duration * n * 0.1, 0.001); // Hack cause it doesn't like 0 for some reason
    const percentage = n * 10;
    player.currentTime = targetTime;
    showMovePopup(targetTime, percentage);
}

function seek_callback(e) {
    'use strict';
    if (!enabled) {
        return;
    }
    if (!e.ctrlKey) {
        return;
    }
    if (e.shiftKey) {
        switch (e.code) {
            case "ArrowUp":
                increase_seek();
                break;
            case "ArrowDown":
                decrease_seek();
                break;
            case "Digit0":
            case "Digit1":
            case "Digit2":
            case "Digit3":
            case "Digit4":
            case "Digit5":
            case "Digit6":
            case "Digit7":
            case "Digit8":
            case "Digit9":
                seek_percent(Number(e.code[5]));
                break;
            default:
                break;
        }
    } else {
        switch (e.code) {
            case "ArrowRight":
                seek_forwards();
                break;
            case "ArrowLeft":
                seek_backwards();
                break;
            default:
                break;
        }
    }
}

function enable_twitch_seeking() {
    player = null;
    seek = 60; // default seek amount
    enabled = true;
    document.addEventListener('keyup', seek_callback, false);
    console.log("enabling twitch seeking for " + window.location);
}

function disable_twitch_seeking() {
    player = null;
    seek = 60;
    enabled = false;
    document.removeEventListener('keyup', seek_callback, false);
    console.log("disabling twitch seeking for " + window.location);
}


// @match        *://*.twitch.tv/video/*
// @match        *://*.twitch.tv/videos/*
// @match        *://*.twitch.tv/*/video/*
// @match        *://*.twitch.tv/*/videos/*
// @match        *://*.twitch.tv/clip/*
// @match        *://*.twitch.tv/*/clip/*
// @match        *://clips.twitch.tv/*
function handleNavigate() {
    const pathname = window.location.pathname;

    const hostname = window.location.hostname;


    // Define regular expressions for each pattern
    const matchPatterns = [
        // Matches "/video[s]/*"
        /^\/videos?\//,

        // Matches "/<user>/video[s]/*"
        /^\/[^/]+\/videos?\//,

        // Matches "/clip[s]/*"
        /^\/clips?\//,

        // Matches "/<user>/clip[s]/*"
        /^\/[^/]+\/clips?\//,
    ];
    const isTwitchMatch = matchPatterns.some(pattern => pattern.test(pathname));
    const isClipMatch = hostname === 'clips.twitch.tv';
    if (isTwitchMatch || isClipMatch) {
        enable_twitch_seeking();
    } else {
        disable_twitch_seeking();
    }
}

(function() {
    'use strict';
    handleNavigate();
    /* Prioritize navigation api if it exists. As of 2024-12-13 this does not work in firefox
     * See
     * https://developer.mozilla.org/en-US/docs/Web/API/Navigation/navigatesuccess_event#browser_compatibility
     * and
     * https://bugzilla.mozilla.org/show_bug.cgi?id=1777171
     */
    if (self.navigation) {
        self.navigation.addEventListener('navigatesuccess', handleNavigate);
    } else {
        let u = location.href;
        new MutationObserver(() => u !== (u = location.href) && handleNavigate())
            .observe(document, {subtree: true, childList: true});
    }
})();