YouTube Hotkeys

Navigate YouTube with leader key 'i' followed by other keys

  1. // ==UserScript==
  2. // @name YouTube Hotkeys
  3. // @namespace Violentmonkey Scripts
  4. // @version 2.0
  5. // @description Navigate YouTube with leader key 'i' followed by other keys
  6. // @author dpi0
  7. // @author You
  8. // @match https://www.youtube.com/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_setClipboard
  13. // @grant window.close
  14. // @homepageURL https://github.com/dpi0/scripts/blob/main/greasyfork/youtube-hotkeys.js
  15. // @supportURL https://github.com/dpi0/scripts/issues
  16. // @license MIT
  17. // ==/UserScript==
  18.  
  19. (function () {
  20. "use strict";
  21.  
  22. // Configuration with default values
  23. const DEFAULT_LEADER_KEY = "i";
  24. const TIMEOUT = 2000; // Time window in ms to press the second key after leader key
  25.  
  26. // Get leader key from GM storage, or use default if not set
  27. let LEADER_KEY = GM_getValue("leaderKey", DEFAULT_LEADER_KEY);
  28.  
  29. // Setup Violentmonkey/Tampermonkey menu commands
  30. GM_registerMenuCommand("🔑 Change Leader Key", promptForLeaderKey);
  31. GM_registerMenuCommand("🗘 Reset Leader Key", resetLeaderKey);
  32.  
  33. // Function to prompt user for new leader key
  34. function promptForLeaderKey() {
  35. const newKey = prompt("Enter a new leader key:", LEADER_KEY);
  36. if (newKey && newKey.length === 1) {
  37. LEADER_KEY = newKey.toLowerCase();
  38. GM_setValue("leaderKey", LEADER_KEY);
  39. showNotification(`Leader key changed to '${LEADER_KEY}'`);
  40. } else if (newKey) {
  41. alert("Leader key must be a single character.");
  42. }
  43. }
  44.  
  45. // Function to reset leader key to default
  46. function resetLeaderKey() {
  47. LEADER_KEY = DEFAULT_LEADER_KEY;
  48. GM_setValue("leaderKey", LEADER_KEY);
  49. showNotification(`Leader key reset to '${LEADER_KEY}'`);
  50. }
  51.  
  52. // Navigation and action functions
  53. function navigateToHome() {
  54. window.location.href = "https://www.youtube.com/";
  55. }
  56.  
  57. function navigateToSubscriptions() {
  58. window.location.href = "https://www.youtube.com/feed/subscriptions";
  59. }
  60.  
  61. function navigateToHistory() {
  62. window.location.href = "https://www.youtube.com/feed/history";
  63. }
  64.  
  65. function navigateToWatchLater() {
  66. window.location.href = "https://www.youtube.com/playlist?list=WL";
  67. }
  68.  
  69. function navigateToLikedVideos() {
  70. window.location.href = "https://www.youtube.com/playlist?list=LL";
  71. }
  72.  
  73. function navigateToTrending() {
  74. window.location.href = "https://www.youtube.com/feed/trending";
  75. }
  76.  
  77. function navigateToLibrary() {
  78. window.location.href = "https://www.youtube.com/feed/library";
  79. }
  80.  
  81. function navigateToChannelVideos() {
  82. // Fixed function to work on both channel pages and video pages
  83. if (window.location.pathname.includes("/watch")) {
  84. // If on a video page, find the channel link
  85. const channelLink =
  86. document.querySelector("#top-row ytd-video-owner-renderer a") ||
  87. document.querySelector("ytd-channel-name a") ||
  88. document.querySelector("a.ytd-channel-name");
  89.  
  90. if (channelLink) {
  91. // Get the channel URL and append /videos
  92. let channelUrl = channelLink.href;
  93. if (!channelUrl.endsWith("/videos")) {
  94. channelUrl = channelUrl.split("?")[0]; // Remove any query parameters
  95. channelUrl = channelUrl.endsWith("/")
  96. ? channelUrl + "videos"
  97. : channelUrl + "/videos";
  98. }
  99. window.location.href = channelUrl;
  100. } else {
  101. showNotification("Channel link not found on this video page!");
  102. }
  103. } else if (
  104. window.location.pathname.includes("/channel/") ||
  105. window.location.pathname.includes("/c/") ||
  106. window.location.pathname.includes("/user/") ||
  107. window.location.pathname.includes("/@")
  108. ) {
  109. // If already on a channel page, navigate to videos section
  110. // Extract the channel name/ID from the URL
  111. const channelPath = window.location.pathname.split("/")[1]; // Get the @CHANNEL_NAME part
  112. window.location.href = `https://www.youtube.com/${channelPath}/videos`;
  113. } else {
  114. showNotification("Not on a video or channel page!");
  115. }
  116. }
  117.  
  118. function navigateToChannelPlaylists() {
  119. // Fixed function to work on both channel pages and video pages
  120. if (window.location.pathname.includes("/watch")) {
  121. // If on a video page, find the channel link
  122. const channelLink =
  123. document.querySelector("#top-row ytd-video-owner-renderer a") ||
  124. document.querySelector("ytd-channel-name a") ||
  125. document.querySelector("a.ytd-channel-name");
  126.  
  127. if (channelLink) {
  128. // Get the channel URL and append /playlists
  129. let channelUrl = channelLink.href;
  130. if (!channelUrl.endsWith("/playlists")) {
  131. channelUrl = channelUrl.split("?")[0]; // Remove any query parameters
  132. channelUrl = channelUrl.endsWith("/")
  133. ? channelUrl + "playlists"
  134. : channelUrl + "/playlists";
  135. }
  136. window.location.href = channelUrl;
  137. } else {
  138. showNotification("Channel link not found on this video page!");
  139. }
  140. } else if (
  141. window.location.pathname.includes("/channel/") ||
  142. window.location.pathname.includes("/c/") ||
  143. window.location.pathname.includes("/user/") ||
  144. window.location.pathname.includes("/@")
  145. ) {
  146. // If already on a channel page, navigate to playlists section
  147. // Extract the channel name/ID from the URL
  148. const channelPath = window.location.pathname.split("/")[1]; // Get the @CHANNEL_NAME part
  149. window.location.href = `https://www.youtube.com/${channelPath}/playlists`;
  150. } else {
  151. showNotification("Not on a video or channel page!");
  152. }
  153. }
  154.  
  155. function triggerSaveButton() {
  156. // Only works on watch pages
  157. if (!window.location.pathname.includes("/watch")) {
  158. showNotification("This only works on video pages!");
  159. return;
  160. }
  161.  
  162. // Try to find the Save button using various selectors
  163. const saveButton =
  164. document.querySelector('button[aria-label="Save to playlist"]') ||
  165. document.querySelector('ytd-button-renderer[id="save-button"]') ||
  166. document.querySelector('ytd-menu-renderer button[aria-label="Save"]') ||
  167. document.querySelector('button.ytd-menu-renderer[aria-label="Save"]') ||
  168. document.querySelector('button[aria-label="Save"]');
  169.  
  170. if (saveButton) {
  171. saveButton.click();
  172. showNotification("Save to playlist popup triggered");
  173. } else {
  174. showNotification("Save button not found!");
  175. }
  176. }
  177.  
  178. function navigateToNextVideo() {
  179. // Only works on watch pages
  180. if (!window.location.pathname.includes("/watch")) {
  181. showNotification("This only works on video pages!");
  182. return;
  183. }
  184.  
  185. // Try to find the "Next" button and click it
  186. const nextButton = findNextButton();
  187. if (nextButton) {
  188. nextButton.click();
  189. // No need for notification as the page will navigate
  190. } else {
  191. showNotification("Next video button not found!");
  192. }
  193. }
  194.  
  195. function navigateToPreviousVideo() {
  196. // Only works on watch pages
  197. if (!window.location.pathname.includes("/watch")) {
  198. showNotification("This only works on video pages!");
  199. return;
  200. }
  201.  
  202. // YouTube doesn't have a standard "Previous video" button
  203. // This is just a placeholder, as YouTube doesn't have a native "previous video" button
  204. showNotification("Previous video navigation not supported by YouTube");
  205. }
  206.  
  207. function toggleSidebar() {
  208. // Find and click the guide button (hamburger menu)
  209. const guideButton =
  210. document.querySelector("#guide-button") ||
  211. document.querySelector('button[aria-label="Guide"]') ||
  212. document.querySelector('button[aria-label="Menu"]');
  213.  
  214. if (guideButton) {
  215. guideButton.click();
  216. showNotification("Toggled sidebar");
  217. } else {
  218. showNotification("Sidebar toggle button not found!");
  219. }
  220. }
  221.  
  222. function copyVideoUrlWithTimestamp() {
  223. // Only works on watch pages
  224. if (!window.location.pathname.includes("/watch")) {
  225. showNotification("This only works on video pages!");
  226. return;
  227. }
  228.  
  229. // Get current video time
  230. const video = document.querySelector("video");
  231. if (!video) {
  232. showNotification("Video element not found!");
  233. return;
  234. }
  235.  
  236. const currentTime = Math.floor(video.currentTime);
  237. const currentUrl = window.location.href.split("&t=")[0]; // Remove any existing timestamp
  238. const urlWithTimestamp = `${currentUrl}&t=${currentTime}s`;
  239.  
  240. // Copy to clipboard
  241. try {
  242. navigator.clipboard
  243. .writeText(urlWithTimestamp)
  244. .then(() => {
  245. showNotification("Video URL with timestamp copied to clipboard!");
  246. })
  247. .catch((err) => {
  248. console.error("Failed to copy: ", err);
  249. showNotification("Failed to copy URL");
  250. });
  251. } catch (e) {
  252. // Fallback for browsers that don't support clipboard API
  253. const textarea = document.createElement("textarea");
  254. textarea.value = urlWithTimestamp;
  255. document.body.appendChild(textarea);
  256. textarea.select();
  257. document.execCommand("copy");
  258. document.body.removeChild(textarea);
  259. showNotification("Video URL with timestamp copied to clipboard!");
  260. }
  261. }
  262.  
  263. // Helper function to find the Next button
  264. function findNextButton() {
  265. // YouTube's UI changes frequently, so we need multiple selectors
  266. const selectors = [
  267. ".ytp-next-button", // Old UI next button
  268. "a.ytp-next-button", // Another variation
  269. ".ytd-watch-next-secondary-results-renderer button", // Newer UI
  270. 'button[aria-label="Next"]', // Generic aria-label approach
  271. 'ytd-button-renderer button[aria-label="Next"]', // More specific
  272. // Add more selectors as YouTube's UI changes
  273. ];
  274.  
  275. for (const selector of selectors) {
  276. const button = document.querySelector(selector);
  277. if (button) return button;
  278. }
  279.  
  280. return null;
  281. }
  282.  
  283. function copyShortenedUrl() {
  284. if (!window.location.pathname.includes("/watch")) {
  285. showNotification("This only works on video pages!");
  286. return;
  287. }
  288.  
  289. const urlParams = new URLSearchParams(window.location.search);
  290. const videoId = urlParams.get("v");
  291. if (!videoId) {
  292. showNotification("Video ID not found!");
  293. return;
  294. }
  295.  
  296. const shortUrl = `https://youtu.be/${videoId}`;
  297. try {
  298. navigator.clipboard
  299. .writeText(shortUrl)
  300. .then(() => {
  301. showNotification("Shortened URL copied to clipboard!");
  302. })
  303. .catch((err) => {
  304. console.error("Clipboard write failed:", err);
  305. fallbackCopyToClipboard(shortUrl);
  306. });
  307. } catch (e) {
  308. fallbackCopyToClipboard(shortUrl);
  309. }
  310.  
  311. function fallbackCopyToClipboard(text) {
  312. const textarea = document.createElement("textarea");
  313. textarea.value = text;
  314. document.body.appendChild(textarea);
  315. textarea.select();
  316. document.execCommand("copy");
  317. document.body.removeChild(textarea);
  318. showNotification("Shortened URL copied to clipboard!");
  319. }
  320. }
  321.  
  322. function navigateToCommunityTab() {
  323. const base = window.location.origin;
  324. let channelPath = null;
  325.  
  326. if (window.location.pathname.includes("/watch")) {
  327. const channelLink =
  328. document.querySelector("#top-row ytd-video-owner-renderer a") ||
  329. document.querySelector("ytd-channel-name a") ||
  330. document.querySelector("a.ytd-channel-name");
  331.  
  332. if (channelLink) {
  333. const url = new URL(channelLink.href);
  334. channelPath = url.pathname;
  335. }
  336. } else {
  337. const match = window.location.pathname.match(
  338. /^\/(channel|c|user|@[^\/]+)(\/.*)?$/,
  339. );
  340. if (match) {
  341. channelPath = `/${match[1]}`;
  342. }
  343. }
  344.  
  345. if (channelPath) {
  346. window.location.href = `${base}${channelPath}/community`;
  347. } else {
  348. showNotification("Unable to resolve channel path for community tab.");
  349. }
  350. }
  351.  
  352. function showHelpModal() {
  353. // Remove existing modal if present
  354. const existing = document.getElementById("yt-hotkey-help-modal");
  355. if (existing) existing.remove();
  356.  
  357. // Create overlay
  358. const overlay = document.createElement("div");
  359. overlay.id = "yt-hotkey-help-modal";
  360. overlay.style.position = "fixed";
  361. overlay.style.top = "0";
  362. overlay.style.left = "0";
  363. overlay.style.width = "100vw";
  364. overlay.style.height = "100vh";
  365. overlay.style.backgroundColor = "rgba(0, 0, 0, 0.6)";
  366. overlay.style.zIndex = "10000";
  367. overlay.style.display = "flex";
  368. overlay.style.justifyContent = "center";
  369. overlay.style.alignItems = "center";
  370.  
  371. // Modal content
  372. const modal = document.createElement("div");
  373. modal.style.backgroundColor = "#fff";
  374. modal.style.borderRadius = "8px";
  375. modal.style.padding = "20px 30px";
  376. modal.style.maxWidth = "600px";
  377. modal.style.maxHeight = "80vh";
  378. modal.style.overflowY = "auto";
  379. modal.style.boxShadow = "0 0 10px rgba(0,0,0,0.5)";
  380. modal.style.fontFamily = "Arial, sans-serif";
  381.  
  382. const title = document.createElement("h2");
  383. title.textContent = "YouTube Leader Key Hotkeys";
  384. title.style.marginTop = "0";
  385.  
  386. const table = document.createElement("table");
  387. table.style.width = "100%";
  388. table.style.borderCollapse = "collapse";
  389.  
  390. const rows = Object.entries(HOTKEYS).map(([key, fn]) => {
  391. const row = document.createElement("tr");
  392.  
  393. const keyCell = document.createElement("td");
  394. keyCell.textContent = `i + ${key}`;
  395. keyCell.style.fontWeight = "bold";
  396. keyCell.style.padding = "4px 8px";
  397. keyCell.style.borderBottom = "1px solid #ddd";
  398. keyCell.style.whiteSpace = "nowrap";
  399.  
  400. const descCell = document.createElement("td");
  401. descCell.textContent = fn.name
  402. .replace(/navigateTo|copy|toggle|trigger|show/i, "")
  403. .replace(/([A-Z])/g, " $1")
  404. .trim();
  405. descCell.style.padding = "4px 8px";
  406. descCell.style.borderBottom = "1px solid #ddd";
  407. descCell.style.textTransform = "capitalize";
  408.  
  409. row.appendChild(keyCell);
  410. row.appendChild(descCell);
  411. return row;
  412. });
  413.  
  414. rows.forEach((row) => table.appendChild(row));
  415.  
  416. const closeBtn = document.createElement("button");
  417. closeBtn.textContent = "Close";
  418. closeBtn.style.marginTop = "16px";
  419. closeBtn.style.padding = "8px 16px";
  420. closeBtn.style.border = "none";
  421. closeBtn.style.background = "#cc0000";
  422. closeBtn.style.color = "white";
  423. closeBtn.style.borderRadius = "4px";
  424. closeBtn.style.cursor = "pointer";
  425. closeBtn.onclick = () => overlay.remove();
  426.  
  427. modal.appendChild(title);
  428. modal.appendChild(table);
  429. modal.appendChild(closeBtn);
  430. overlay.appendChild(modal);
  431. document.body.appendChild(overlay);
  432. }
  433.  
  434. // Shows a brief notification to the user
  435. function showNotification(message, duration = 2000) {
  436. const notification = document.createElement("div");
  437. notification.textContent = message;
  438. notification.style.position = "fixed";
  439. notification.style.top = "20px";
  440. notification.style.left = "50%";
  441. notification.style.transform = "translateX(-50%)";
  442. notification.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
  443. notification.style.color = "white";
  444. notification.style.padding = "10px 20px";
  445. notification.style.borderRadius = "4px";
  446. notification.style.zIndex = "9999";
  447. notification.style.fontFamily = "Arial, sans-serif";
  448. notification.style.textAlign = "center";
  449. notification.style.maxWidth = "80%";
  450.  
  451. document.body.appendChild(notification);
  452.  
  453. setTimeout(() => {
  454. notification.style.opacity = "0";
  455. notification.style.transition = "opacity 0.5s ease";
  456. setTimeout(() => document.body.removeChild(notification), 500);
  457. }, duration);
  458. }
  459.  
  460. // Hotkey mappings with functions - Updated per user preference
  461. const HOTKEYS = {
  462. h: navigateToHome, // i -> h for home
  463. s: navigateToSubscriptions, // i -> s for subscriptions
  464. e: navigateToHistory, // i -> e for history
  465. w: navigateToWatchLater, // i -> w for watch later
  466. l: navigateToLikedVideos, // i -> l for liked videos
  467. t: navigateToTrending, // i -> t for trending
  468. L: navigateToLibrary, // i -> L (capital) for library
  469. y: copyVideoUrlWithTimestamp, // i -> y for copy URL with timestamp
  470. v: navigateToChannelVideos, // i -> a for channel videos
  471. q: navigateToChannelPlaylists, // i -> q for channel playlists
  472. n: navigateToNextVideo, // i -> n for next video
  473. p: navigateToPreviousVideo, // i -> p for previous video
  474. Tab: toggleSidebar, // i -> Tab for toggle sidebar
  475. S: triggerSaveButton, // i -> s (capital) for Save to playlist popup
  476. Y: copyShortenedUrl, // i -> Y (capital) for shortened URL
  477. C: navigateToCommunityTab, // i -> C (capital) for community tab
  478. "?": showHelpModal,
  479. };
  480.  
  481. // State variables
  482. let leaderPressed = false;
  483. let leaderTimer = null;
  484.  
  485. // Function to handle keydown events
  486. function handleKeyDown(event) {
  487. // Check if user is typing in an input field
  488. if (isInputField(event.target)) {
  489. return;
  490. }
  491.  
  492. // Get the key that was pressed (preserve case)
  493. const key = event.key;
  494.  
  495. // If leader key is pressed
  496. if (key.toLowerCase() === LEADER_KEY) {
  497. // Prevent default action (like mini player)
  498. event.preventDefault();
  499. event.stopPropagation();
  500.  
  501. // Set the leader state
  502. leaderPressed = true;
  503.  
  504. // Clear any existing timer
  505. if (leaderTimer) {
  506. clearTimeout(leaderTimer);
  507. }
  508.  
  509. // Set a timeout to reset the leader state
  510. leaderTimer = setTimeout(() => {
  511. leaderPressed = false;
  512. }, TIMEOUT);
  513.  
  514. return;
  515. }
  516.  
  517. // If a key is pressed after the leader key
  518. if (leaderPressed && HOTKEYS[key]) {
  519. // Prevent default action
  520. event.preventDefault();
  521. event.stopPropagation();
  522.  
  523. // Execute the function associated with the key
  524. HOTKEYS[key]();
  525.  
  526. // Reset leader state
  527. leaderPressed = false;
  528. clearTimeout(leaderTimer);
  529. }
  530. }
  531.  
  532. // Helper function to check if the active element is an input field
  533. function isInputField(element) {
  534. const tagName = element.tagName.toLowerCase();
  535. const type = element.type ? element.type.toLowerCase() : "";
  536.  
  537. return (
  538. (tagName === "input" &&
  539. (type === "text" ||
  540. type === "password" ||
  541. type === "email" ||
  542. type === "number" ||
  543. type === "search" ||
  544. type === "tel" ||
  545. type === "url")) ||
  546. tagName === "textarea" ||
  547. element.isContentEditable
  548. );
  549. }
  550.  
  551. // Add event listener for keydown
  552. document.addEventListener("keydown", handleKeyDown, true);
  553.  
  554. // Show initial notification about the leader key on first load
  555. const firstRun = GM_getValue("firstRun", true);
  556. if (firstRun) {
  557. setTimeout(() => {
  558. showNotification(
  559. `YouTube Leader Key Navigation activated! Leader key is '${LEADER_KEY}'`,
  560. 5000,
  561. );
  562. GM_setValue("firstRun", false);
  563. }, 2000);
  564. }
  565.  
  566. // Logging for debugging
  567. console.log(
  568. `YouTube Leader Key Navigation loaded with leader key '${LEADER_KEY}'`,
  569. );
  570. })();