YouTube Playback (XInput)

Xbox controller playback for YouTube with hold-to-repeat

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

Advertisement:

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

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.");

})();