YouTube Chapter Navigation (Shift + Arrows)

Hold Shift and use ← / → to jump between chapters. Works with both manual and auto-generated chapters.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         YouTube Chapter Navigation (Shift + Arrows)
// @namespace    -
// @version      1.0
// @description  Hold Shift and use ← / → to jump between chapters. Works with both manual and auto-generated chapters.
// @author       MrGoatsy
// @match        https://www.youtube.com/watch*
// @grant        none
// @license      CC-BY-NC-ND-4.0
// ==/UserScript==

(() => {
    'use strict';

    // --- Configuration ---
    const DEBUG = false;
    // If you are more than this many seconds into a chapter, the first press of ArrowLeft will rewind to its start.
    // A second press (or a press within this threshold) will go to the previous chapter.
    const REWIND_THRESHOLD = 2.0;

    // --- Script State (resets on new video) ---
    let state = {
        player: null,
        chapters: null, // This will be our cache
        isReady: false
    };

    const log = (...args) => DEBUG && console.log('[YT-ChapterNav]', ...args);

    /**
     * Fetches chapter timestamps, trying the official API first and falling back to visual scraping.
     * @param {HTMLDivElement} player - The #movie_player element.
     * @returns {number[]|null} Sorted array of chapter start times in seconds.
     */
    const fetchChapters = (player) => {
        try { // API method
            const chapters = player.getVideoData()?.chapters;
            if (Array.isArray(chapters) && chapters.length > 1) {
                log('Chapters found via API.');
                return chapters.map(c => (c.chapterRenderer?.timeRangeStartMillis ?? 0) / 1000).sort((a, b) => a - b);
            }
        } catch {}

        try { // Scrape method
            const progressBar = document.querySelector('.ytp-progress-bar');
            const markers = document.querySelectorAll('.ytp-chapter-hover-container');
            const duration = player.getDuration();
            if (!progressBar || markers.length < 2 || !duration) return null;
            const totalWidth = progressBar.clientWidth;
            if (!totalWidth) return null;

            log('Chapters found via progress bar scraping.');
            const times = [0];
            let accumulatedWidth = 0;
            for (let i = 0; i < markers.length - 1; i++) {
                accumulatedWidth += markers[i].clientWidth;
                times.push((accumulatedWidth / totalWidth) * duration);
            }
            return [...new Set(times)].sort((a, b) => a - b);
        } catch {
            return null;
        }
    };

    /**
     * Handles the keydown event for chapter navigation.
     * @param {KeyboardEvent} e
     */
    const onKeydown = (e) => {
        // --- Navigation Logic ---
        // Check if Shift is held, the player is ready, and an arrow key is pressed.
        if (!state.isReady || !e.shiftKey || !['ArrowRight', 'ArrowLeft'].includes(e.key)) {
            return;
        }

        const activeElement = document.activeElement;
        const isTyping = activeElement?.isContentEditable || ['INPUT', 'TEXTAREA'].includes(activeElement?.tagName);
        if (isTyping) return;

        // Prevent default browser actions for Shift+Arrow (e.g., text selection)
        e.preventDefault();
        e.stopPropagation();

        // Fetch and cache chapters on the first use
        if (state.chapters === null) {
            state.chapters = fetchChapters(state.player) || [];
        }
        if (state.chapters.length === 0) return log('No chapters to navigate.');

        const currentTime = state.player.getCurrentTime();
        const currentIndex = state.chapters.findLastIndex(t => t <= currentTime);
        if (currentIndex === -1) return;

        if (e.key === 'ArrowRight') {
            if (currentIndex < state.chapters.length - 1) {
                state.player.seekTo(state.chapters[currentIndex + 1], true);
            }
        } else { // ArrowLeft
            const currentChapterStartTime = state.chapters[currentIndex];
            if (currentTime > currentChapterStartTime + REWIND_THRESHOLD && currentIndex > 0) {
                state.player.seekTo(currentChapterStartTime, true);
            } else {
                const prevChapterTime = (currentIndex > 0) ? state.chapters[currentIndex - 1] : 0;
                state.player.seekTo(prevChapterTime, true);
            }
        }
    };

    /**
     * Resets state and waits for the player to be ready on a new page/video.
     */
    const initializeForPage = () => {
        log('Initializing for new video...');
        state = { player: null, chapters: null, isReady: false };

        const interval = setInterval(() => {
            const player = document.getElementById('movie_player');
            if (player && typeof player.getDuration === 'function' && player.getDuration() > 0) {
                clearInterval(interval);
                state.player = player;
                state.isReady = true;
                log('Player is ready.');
            }
        }, 300);
    };

    // --- Entry Point ---
    document.addEventListener('keydown', onKeydown, true);
    document.addEventListener('yt-navigate-finish', initializeForPage);
    initializeForPage();
})();