YouTube Chapter Navigation (Shift + Arrows)

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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();
})();