YouTube Playback (XInput)

Xbox controller playback for YouTube with hold-to-repeat

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!)

Advertisement:

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!)

Advertisement:

// ==UserScript==
// @name         YouTube Playback (XInput)
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Xbox controller playback for YouTube with hold-to-repeat
// @author       kidpoleon
// @match        *://*.youtube.com/*
// @grant        none
// @license      MIT
// @icon         https://www.google.com/s2/favicons?domain=youtube.com
// ==/UserScript==

(function() {
    'use strict';

    let activeGamepadIndex = null;
    let previousButtons = [];
    let buttonStates = {}; // Tracks hold durations
    let animationFrameId = null;

    // Timing configurations for "Hold-to-Repeat" (in milliseconds)
    const HOLD_DELAY = 400;   // How long to hold before repeating starts
    const REPEAT_RATE = 100;  // Speed of repeats once held (100ms = 10 times a second)

    // Standard XInput Button Map
    const BUTTON = {
        A: 0,          // Play / Pause
        B: 1,          // Subtitles Toggle
        X: 2,          // Mute / Unmute
        Y: 3,          // Fullscreen Toggle
        LB: 4,         // Back 1s
        RB: 5,         // Forward 1s
        LT: 6,         // Frame Backwards (Commas)
        RT: 7,         // Frame Forwards (Periods)
        SELECT: 8,     // Previous Video / Previous in Playlist
        START: 9,      // Next Video / Next in Playlist
        L3: 10,        // Toggle Picture-in-Picture
        R3: 11,        // Toggle Comment Sidebar (fullscreen mode)
        DPAD_UP: 12,   // Vol +5%
        DPAD_DOWN: 13, // Vol -5%
        DPAD_LEFT: 14, // Back 5s
        DPAD_RIGHT: 15 // Forward 5s
    };

    // Only these buttons are allowed to "auto-repeat" when held down
    const REPEATABLE_BUTTONS = [
        BUTTON.DPAD_LEFT, BUTTON.DPAD_RIGHT,
        BUTTON.DPAD_UP, BUTTON.DPAD_DOWN,
        BUTTON.LT, BUTTON.RT,
        BUTTON.LB, BUTTON.RB
    ];

    // Helper to get YouTube's internal player API safely
    function getPlayer() {
        return document.getElementById('movie_player');
    }

    // Helper to simulate keyboard events (for Volume, Fullscreen, Frame-by-Frame, Stats)
    function simulateKey(key, code, keyCode, ctrlKey = false, shiftKey = false) {
        const event = new KeyboardEvent('keydown', {
            key: key, code: code, keyCode: keyCode, which: keyCode,
            ctrlKey: ctrlKey, shiftKey: shiftKey, bubbles: true, cancelable: true, composed: true
        });
        document.body.dispatchEvent(event);
    }


    // Handle individual button actions
    function executeAction(buttonIndex) {
        const player = getPlayer();
        const video = document.querySelector('video');

        if (!player && !video) return;

        switch (buttonIndex) {
            case BUTTON.A:
                // Play / Pause
                if (player && player.getPlayerState) {
                    player.getPlayerState() === 1 ? player.pauseVideo() : player.playVideo();
                } else if (video) {
                    video.paused ? video.play() : video.pause();
                }
                break;

            case BUTTON.X:
                // Mute / Unmute
                if (player && player.isMuted) {
                    player.isMuted() ? player.unMute() : player.mute();
                } else if (video) {
                    video.muted = !video.muted;
                }
                break;

            case BUTTON.B:
                // Toggle Subtitles (Closed Captions)
                const ccBtn = document.querySelector('.ytp-subtitles-button');
                if (ccBtn) {
                    ccBtn.click();
                } else if (player && player.toggleSubtitles) {
                    player.toggleSubtitles();
                }
                break;

            case BUTTON.DPAD_RIGHT:
                // Forward 5 seconds
                if (player && player.seekBy) player.seekBy(5);
                else if (video) video.currentTime += 5;
                break;

            case BUTTON.DPAD_LEFT:
                // Backward 5 seconds
                if (player && player.seekBy) player.seekBy(-5);
                else if (video) video.currentTime -= 5;
                break;

            case BUTTON.DPAD_UP:
                // Volume Up (+5%)
                if (player && player.getVolume) {
                    player.setVolume(Math.min(100, player.getVolume() + 5));
                    if(player.isMuted()) player.unMute();
                }
                break;

            case BUTTON.DPAD_DOWN:
                // Volume Down (-5%)
                if (player && player.getVolume) {
                    player.setVolume(Math.max(0, player.getVolume() - 5));
                }
                break;

            case BUTTON.RB:
                // Forward 1 second
                if (player && player.seekBy) player.seekBy(1);
                else if (video) video.currentTime += 1;
                break;

            case BUTTON.LB:
                // Backward 1 second
                if (player && player.seekBy) player.seekBy(-1);
                else if (video) video.currentTime -= 1;
                break;

            case BUTTON.RT:
                // Frame Forward (Requires Pause)
                if (video && !video.paused) video.pause();
                simulateKey('.', 'Period', 190);
                break;

            case BUTTON.LT:
                // Frame Backward (Requires Pause)
                if (video && !video.paused) video.pause();
                simulateKey(',', 'Comma', 188);
                break;

            case BUTTON.Y:
                // Fullscreen Toggle - Click fullscreen button directly for reliability
                const fullscreenBtn = document.querySelector('.ytp-fullscreen-button');
                if (fullscreenBtn) {
                    fullscreenBtn.click();
                } else if (player && player.toggleFullscreen) {
                    player.toggleFullscreen();
                } else {
                    // Fallback to keyboard simulation
                    simulateKey('f', 'KeyF', 70);
                }
                break;

            case BUTTON.SELECT:
                // Previous Video / Previous in Playlist
                simulateKey('p', 'KeyP', 80, false, true);
                break;

            case BUTTON.START:
                // Next Video / Next in Playlist
                simulateKey('n', 'KeyN', 78, false, true);
                break;

            case BUTTON.L3:
                // Toggle Picture-in-Picture
                const video = document.querySelector('video');
                if (video) {
                    if (document.pictureInPictureElement) {
                        document.exitPictureInPicture();
                    } else if (video.requestPictureInPicture) {
                        video.requestPictureInPicture();
                    }
                }
                break;

            case BUTTON.R3:
                // Toggle Comment Sidebar (fullscreen mode)
                const commentBtn = document.evaluate('/html/body/ytd-app/div[1]/ytd-page-manager/ytd-watch-flexy/div[2]/div[1]/div[3]/ytd-player/div/div/div[7]/div[4]/div[2]/yt-player-quick-action-buttons/toggle-button-view-model/button-view-model/button', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
                if (commentBtn) {
                    commentBtn.click();
                }
                break;
        }
    }

    // The main polling loop with Hold-to-Repeat logic
    function updateLoop() {
        if (activeGamepadIndex === null) return;

        // Only process input when the document has focus and tab is visible
        if (!document.hasFocus() || document.visibilityState !== 'visible') {
            animationFrameId = requestAnimationFrame(updateLoop);
            return;
        }

        const pad = navigator.getGamepads()[activeGamepadIndex];
        if (!pad) return;

        const now = performance.now();

        for (let i = 0; i < pad.buttons.length; i++) {
            const isPressed = pad.buttons[i].pressed;
            const wasPressed = previousButtons[i];

            if (isPressed) {
                if (!wasPressed) {
                    // 1. Initial Press (Edge Detection)
                    executeAction(i);
                    buttonStates[i] = { pressStartTime: now, lastActionTime: now };
                } else if (buttonStates[i] && REPEATABLE_BUTTONS.includes(i)) {
                    // 2. Held Down (Auto-Repeat Logic)
                    const holdDuration = now - buttonStates[i].pressStartTime;

                    if (holdDuration > HOLD_DELAY) {
                        const timeSinceLastAction = now - buttonStates[i].lastActionTime;

                        if (timeSinceLastAction > REPEAT_RATE) {
                            executeAction(i);
                            buttonStates[i].lastActionTime = now;
                        }
                    }
                }
            } else {
                // Button released, clean up the timer
                if (wasPressed) {
                    delete buttonStates[i];
                }
            }

            previousButtons[i] = isPressed;
        }

        animationFrameId = requestAnimationFrame(updateLoop);
    }

    // Connection listener
    window.addEventListener('gamepadconnected', (e) => {
        console.log(`🎮 Gamepad connected: ${e.gamepad.id}`);
        activeGamepadIndex = e.gamepad.index;
        previousButtons = new Array(e.gamepad.buttons.length).fill(false);
        buttonStates = {};

        if (animationFrameId) cancelAnimationFrame(animationFrameId);
        updateLoop();
    });

    // Disconnection listener
    window.addEventListener('gamepaddisconnected', (e) => {
        if (e.gamepad.index === activeGamepadIndex) {
            console.log("🎮 Gamepad disconnected.");
            activeGamepadIndex = null;
            if (animationFrameId) cancelAnimationFrame(animationFrameId);
        }
    });

    console.log("🎮 YouTube Playback (XInput) script loaded. Press any button to activate.");

})();