YouTube Playback (XInput)

Xbox controller playback for YouTube with hold-to-repeat

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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

})();