YouTube Playback (XInput)

Xbox controller playback for YouTube with hold-to-repeat

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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

})();