YouTube Playback (XInput)

Xbox controller playback for YouTube with hold-to-repeat

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

})();