Greasy Fork is available in English.

Twitch Seeking

Keyboard shortcuts to seek more easily in Twitch VODs

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         Twitch Seeking
// @namespace    https://greasyfork.org/users/45933
// @version      0.4.6
// @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;

// 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";
    seekPopup.style.opacity = "0";
    seekPopup.style.transition = "opacity 0.25s ease-out";
    document.body.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.opacity = "0";
    movePopup.style.transition = "opacity 0.25s ease-out";
    document.body.appendChild(movePopup);
}

var hide_popup = 250;

// by chatgpt
function showChangeSeekPopup() {
    if (!seekPopup) {
        createSeekPopup(); // Ensure popup exists
    }

    // Set the text to the current seek amount
    seekPopup.textContent = `Seek: ${seek}s`;

    // Show the popup with a fade-in effect
    seekPopup.style.opacity = "1";

    // Hide the popup after 0.5 seconds (adjustable)
    setTimeout(() => {
        seekPopup.style.opacity = "0";
    }, hide_popup);
}

// by chatgpt
function showSeekPopup(amount, direction, targetTime) {
    if (!seekPopup) {
        createSeekPopup();
    }

    const symbol = direction === "forward" ? ">>" : "<<";
    const formattedTime = formatTime(targetTime);
    seekPopup.textContent = `${symbol} ${amount}s to ${formattedTime}`;
    seekPopup.style.opacity = "1";
    setTimeout(() => {
        seekPopup.style.opacity = "0";
    }, hide_popup);
}

// by chatgpt
function showMovePopup(targetTime, percentage) {
    if (!movePopup) {
        createMovePopup();
    }

    const formattedTime = formatTime(targetTime);
    movePopup.textContent = `${formattedTime} (${percentage}%)`;
    movePopup.style.opacity = "1";
    setTimeout(() => {
        movePopup.style.opacity = "0";
    }, 250);
}

function ensure_player() {
    'use strict';

    if (!player) {
        // Initialize only once
        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(); // Display the seek amount visually
}

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(); // Display the seek amount visually
}

function seek_forwards() {
    'use strict';

    ensure_seeking();
    const before = player.currentTime;
    const targetTime = before + seek;
    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 = before - seek;
    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 = player.duration * n * 0.1;
    const percentage = n * 10;
    player.currentTime = targetTime;
    showMovePopup(targetTime, percentage);
}

function seek_callback(e) {
    'use strict';

    if (!enabled) {
        return;
    }

    if (!e.ctrlKey) {
        // Not holding control; quit
        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;
    // default to 60s
    seek = 60;
    enabled = true;
    // This is too early to find the video player, so delay initialization till key presses.
    // FIXME: Find out how to delay this till it's available
    document.addEventListener('keyup', seek_callback, false);
    console.log("enabling twitch seeking for " + window.location);
}

function disable_twitch_seeking() {
    // Clear player
    player = null;
    // default to 60s
    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?\//,
    ];

    // Check if we're on the main twitch.tv domain and if the pathname matches any of the patterns
    const isTwitchMatch = matchPatterns.some(pattern => pattern.test(pathname));

    // Check if we're on the clips.twitch.tv subdomain (we look at the full URL here)
    const isClipMatch = hostname === 'clips.twitch.tv';

    // Apply the logic
    if (isTwitchMatch || isClipMatch) {
        enable_twitch_seeking();
    } else {
        disable_twitch_seeking();
    }
}

(function() {
    'use strict';

    // Initial load
    handleNavigate();

    //window.addEventListener('popstate', handleNavigate);
    //window.addEventListener('pushState', 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});
    }
})();