Xbox controller playback for YouTube with hold-to-repeat
// ==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.");
})();