YouTube Hotkeys

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Hotkeys
// @namespace    Violentmonkey Scripts
// @version      2.0
// @description  Navigate YouTube with leader key 'i' followed by other keys
// @author       dpi0
// @author       You
// @match        https://www.youtube.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @grant        window.close
// @homepageURL https://github.com/dpi0/scripts/blob/main/greasyfork/youtube-hotkeys.js
// @supportURL  https://github.com/dpi0/scripts/issues
// @license     MIT
// ==/UserScript==

(function () {
  "use strict";

  // Configuration with default values
  const DEFAULT_LEADER_KEY = "i";
  const TIMEOUT = 2000; // Time window in ms to press the second key after leader key

  // Get leader key from GM storage, or use default if not set
  let LEADER_KEY = GM_getValue("leaderKey", DEFAULT_LEADER_KEY);

  // Setup Violentmonkey/Tampermonkey menu commands
  GM_registerMenuCommand("🔑 Change Leader Key", promptForLeaderKey);
  GM_registerMenuCommand("🗘 Reset Leader Key", resetLeaderKey);

  // Function to prompt user for new leader key
  function promptForLeaderKey() {
    const newKey = prompt("Enter a new leader key:", LEADER_KEY);
    if (newKey && newKey.length === 1) {
      LEADER_KEY = newKey.toLowerCase();
      GM_setValue("leaderKey", LEADER_KEY);
      showNotification(`Leader key changed to '${LEADER_KEY}'`);
    } else if (newKey) {
      alert("Leader key must be a single character.");
    }
  }

  // Function to reset leader key to default
  function resetLeaderKey() {
    LEADER_KEY = DEFAULT_LEADER_KEY;
    GM_setValue("leaderKey", LEADER_KEY);
    showNotification(`Leader key reset to '${LEADER_KEY}'`);
  }

  // Navigation and action functions
  function navigateToHome() {
    window.location.href = "https://www.youtube.com/";
  }

  function navigateToSubscriptions() {
    window.location.href = "https://www.youtube.com/feed/subscriptions";
  }

  function navigateToHistory() {
    window.location.href = "https://www.youtube.com/feed/history";
  }

  function navigateToWatchLater() {
    window.location.href = "https://www.youtube.com/playlist?list=WL";
  }

  function navigateToLikedVideos() {
    window.location.href = "https://www.youtube.com/playlist?list=LL";
  }

  function navigateToTrending() {
    window.location.href = "https://www.youtube.com/feed/trending";
  }

  function navigateToLibrary() {
    window.location.href = "https://www.youtube.com/feed/library";
  }

  function navigateToChannelVideos() {
    // Fixed function to work on both channel pages and video pages
    if (window.location.pathname.includes("/watch")) {
      // If on a video page, find the channel link
      const channelLink =
        document.querySelector("#top-row ytd-video-owner-renderer a") ||
        document.querySelector("ytd-channel-name a") ||
        document.querySelector("a.ytd-channel-name");

      if (channelLink) {
        // Get the channel URL and append /videos
        let channelUrl = channelLink.href;
        if (!channelUrl.endsWith("/videos")) {
          channelUrl = channelUrl.split("?")[0]; // Remove any query parameters
          channelUrl = channelUrl.endsWith("/")
            ? channelUrl + "videos"
            : channelUrl + "/videos";
        }
        window.location.href = channelUrl;
      } else {
        showNotification("Channel link not found on this video page!");
      }
    } else if (
      window.location.pathname.includes("/channel/") ||
      window.location.pathname.includes("/c/") ||
      window.location.pathname.includes("/user/") ||
      window.location.pathname.includes("/@")
    ) {
      // If already on a channel page, navigate to videos section
      // Extract the channel name/ID from the URL
      const channelPath = window.location.pathname.split("/")[1]; // Get the @CHANNEL_NAME part
      window.location.href = `https://www.youtube.com/${channelPath}/videos`;
    } else {
      showNotification("Not on a video or channel page!");
    }
  }

  function navigateToChannelPlaylists() {
    // Fixed function to work on both channel pages and video pages
    if (window.location.pathname.includes("/watch")) {
      // If on a video page, find the channel link
      const channelLink =
        document.querySelector("#top-row ytd-video-owner-renderer a") ||
        document.querySelector("ytd-channel-name a") ||
        document.querySelector("a.ytd-channel-name");

      if (channelLink) {
        // Get the channel URL and append /playlists
        let channelUrl = channelLink.href;
        if (!channelUrl.endsWith("/playlists")) {
          channelUrl = channelUrl.split("?")[0]; // Remove any query parameters
          channelUrl = channelUrl.endsWith("/")
            ? channelUrl + "playlists"
            : channelUrl + "/playlists";
        }
        window.location.href = channelUrl;
      } else {
        showNotification("Channel link not found on this video page!");
      }
    } else if (
      window.location.pathname.includes("/channel/") ||
      window.location.pathname.includes("/c/") ||
      window.location.pathname.includes("/user/") ||
      window.location.pathname.includes("/@")
    ) {
      // If already on a channel page, navigate to playlists section
      // Extract the channel name/ID from the URL
      const channelPath = window.location.pathname.split("/")[1]; // Get the @CHANNEL_NAME part
      window.location.href = `https://www.youtube.com/${channelPath}/playlists`;
    } else {
      showNotification("Not on a video or channel page!");
    }
  }

  function triggerSaveButton() {
    // Only works on watch pages
    if (!window.location.pathname.includes("/watch")) {
      showNotification("This only works on video pages!");
      return;
    }

    // Try to find the Save button using various selectors
    const saveButton =
      document.querySelector('button[aria-label="Save to playlist"]') ||
      document.querySelector('ytd-button-renderer[id="save-button"]') ||
      document.querySelector('ytd-menu-renderer button[aria-label="Save"]') ||
      document.querySelector('button.ytd-menu-renderer[aria-label="Save"]') ||
      document.querySelector('button[aria-label="Save"]');

    if (saveButton) {
      saveButton.click();
      showNotification("Save to playlist popup triggered");
    } else {
      showNotification("Save button not found!");
    }
  }

  function navigateToNextVideo() {
    // Only works on watch pages
    if (!window.location.pathname.includes("/watch")) {
      showNotification("This only works on video pages!");
      return;
    }

    // Try to find the "Next" button and click it
    const nextButton = findNextButton();
    if (nextButton) {
      nextButton.click();
      // No need for notification as the page will navigate
    } else {
      showNotification("Next video button not found!");
    }
  }

  function navigateToPreviousVideo() {
    // Only works on watch pages
    if (!window.location.pathname.includes("/watch")) {
      showNotification("This only works on video pages!");
      return;
    }

    // YouTube doesn't have a standard "Previous video" button
    // This is just a placeholder, as YouTube doesn't have a native "previous video" button
    showNotification("Previous video navigation not supported by YouTube");
  }

  function toggleSidebar() {
    // Find and click the guide button (hamburger menu)
    const guideButton =
      document.querySelector("#guide-button") ||
      document.querySelector('button[aria-label="Guide"]') ||
      document.querySelector('button[aria-label="Menu"]');

    if (guideButton) {
      guideButton.click();
      showNotification("Toggled sidebar");
    } else {
      showNotification("Sidebar toggle button not found!");
    }
  }

  function copyVideoUrlWithTimestamp() {
    // Only works on watch pages
    if (!window.location.pathname.includes("/watch")) {
      showNotification("This only works on video pages!");
      return;
    }

    // Get current video time
    const video = document.querySelector("video");
    if (!video) {
      showNotification("Video element not found!");
      return;
    }

    const currentTime = Math.floor(video.currentTime);
    const currentUrl = window.location.href.split("&t=")[0]; // Remove any existing timestamp
    const urlWithTimestamp = `${currentUrl}&t=${currentTime}s`;

    // Copy to clipboard
    try {
      navigator.clipboard
        .writeText(urlWithTimestamp)
        .then(() => {
          showNotification("Video URL with timestamp copied to clipboard!");
        })
        .catch((err) => {
          console.error("Failed to copy: ", err);
          showNotification("Failed to copy URL");
        });
    } catch (e) {
      // Fallback for browsers that don't support clipboard API
      const textarea = document.createElement("textarea");
      textarea.value = urlWithTimestamp;
      document.body.appendChild(textarea);
      textarea.select();
      document.execCommand("copy");
      document.body.removeChild(textarea);
      showNotification("Video URL with timestamp copied to clipboard!");
    }
  }

  // Helper function to find the Next button
  function findNextButton() {
    // YouTube's UI changes frequently, so we need multiple selectors
    const selectors = [
      ".ytp-next-button", // Old UI next button
      "a.ytp-next-button", // Another variation
      ".ytd-watch-next-secondary-results-renderer button", // Newer UI
      'button[aria-label="Next"]', // Generic aria-label approach
      'ytd-button-renderer button[aria-label="Next"]', // More specific
      // Add more selectors as YouTube's UI changes
    ];

    for (const selector of selectors) {
      const button = document.querySelector(selector);
      if (button) return button;
    }

    return null;
  }

  function copyShortenedUrl() {
    if (!window.location.pathname.includes("/watch")) {
      showNotification("This only works on video pages!");
      return;
    }

    const urlParams = new URLSearchParams(window.location.search);
    const videoId = urlParams.get("v");
    if (!videoId) {
      showNotification("Video ID not found!");
      return;
    }

    const shortUrl = `https://youtu.be/${videoId}`;
    try {
      navigator.clipboard
        .writeText(shortUrl)
        .then(() => {
          showNotification("Shortened URL copied to clipboard!");
        })
        .catch((err) => {
          console.error("Clipboard write failed:", err);
          fallbackCopyToClipboard(shortUrl);
        });
    } catch (e) {
      fallbackCopyToClipboard(shortUrl);
    }

    function fallbackCopyToClipboard(text) {
      const textarea = document.createElement("textarea");
      textarea.value = text;
      document.body.appendChild(textarea);
      textarea.select();
      document.execCommand("copy");
      document.body.removeChild(textarea);
      showNotification("Shortened URL copied to clipboard!");
    }
  }

  function navigateToCommunityTab() {
    const base = window.location.origin;
    let channelPath = null;

    if (window.location.pathname.includes("/watch")) {
      const channelLink =
        document.querySelector("#top-row ytd-video-owner-renderer a") ||
        document.querySelector("ytd-channel-name a") ||
        document.querySelector("a.ytd-channel-name");

      if (channelLink) {
        const url = new URL(channelLink.href);
        channelPath = url.pathname;
      }
    } else {
      const match = window.location.pathname.match(
        /^\/(channel|c|user|@[^\/]+)(\/.*)?$/,
      );
      if (match) {
        channelPath = `/${match[1]}`;
      }
    }

    if (channelPath) {
      window.location.href = `${base}${channelPath}/community`;
    } else {
      showNotification("Unable to resolve channel path for community tab.");
    }
  }

  function showHelpModal() {
    // Remove existing modal if present
    const existing = document.getElementById("yt-hotkey-help-modal");
    if (existing) existing.remove();

    // Create overlay
    const overlay = document.createElement("div");
    overlay.id = "yt-hotkey-help-modal";
    overlay.style.position = "fixed";
    overlay.style.top = "0";
    overlay.style.left = "0";
    overlay.style.width = "100vw";
    overlay.style.height = "100vh";
    overlay.style.backgroundColor = "rgba(0, 0, 0, 0.6)";
    overlay.style.zIndex = "10000";
    overlay.style.display = "flex";
    overlay.style.justifyContent = "center";
    overlay.style.alignItems = "center";

    // Modal content
    const modal = document.createElement("div");
    modal.style.backgroundColor = "#fff";
    modal.style.borderRadius = "8px";
    modal.style.padding = "20px 30px";
    modal.style.maxWidth = "600px";
    modal.style.maxHeight = "80vh";
    modal.style.overflowY = "auto";
    modal.style.boxShadow = "0 0 10px rgba(0,0,0,0.5)";
    modal.style.fontFamily = "Arial, sans-serif";

    const title = document.createElement("h2");
    title.textContent = "YouTube Leader Key Hotkeys";
    title.style.marginTop = "0";

    const table = document.createElement("table");
    table.style.width = "100%";
    table.style.borderCollapse = "collapse";

    const rows = Object.entries(HOTKEYS).map(([key, fn]) => {
      const row = document.createElement("tr");

      const keyCell = document.createElement("td");
      keyCell.textContent = `i + ${key}`;
      keyCell.style.fontWeight = "bold";
      keyCell.style.padding = "4px 8px";
      keyCell.style.borderBottom = "1px solid #ddd";
      keyCell.style.whiteSpace = "nowrap";

      const descCell = document.createElement("td");
      descCell.textContent = fn.name
        .replace(/navigateTo|copy|toggle|trigger|show/i, "")
        .replace(/([A-Z])/g, " $1")
        .trim();
      descCell.style.padding = "4px 8px";
      descCell.style.borderBottom = "1px solid #ddd";
      descCell.style.textTransform = "capitalize";

      row.appendChild(keyCell);
      row.appendChild(descCell);
      return row;
    });

    rows.forEach((row) => table.appendChild(row));

    const closeBtn = document.createElement("button");
    closeBtn.textContent = "Close";
    closeBtn.style.marginTop = "16px";
    closeBtn.style.padding = "8px 16px";
    closeBtn.style.border = "none";
    closeBtn.style.background = "#cc0000";
    closeBtn.style.color = "white";
    closeBtn.style.borderRadius = "4px";
    closeBtn.style.cursor = "pointer";
    closeBtn.onclick = () => overlay.remove();

    modal.appendChild(title);
    modal.appendChild(table);
    modal.appendChild(closeBtn);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
  }

  // Shows a brief notification to the user
  function showNotification(message, duration = 2000) {
    const notification = document.createElement("div");
    notification.textContent = message;
    notification.style.position = "fixed";
    notification.style.top = "20px";
    notification.style.left = "50%";
    notification.style.transform = "translateX(-50%)";
    notification.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
    notification.style.color = "white";
    notification.style.padding = "10px 20px";
    notification.style.borderRadius = "4px";
    notification.style.zIndex = "9999";
    notification.style.fontFamily = "Arial, sans-serif";
    notification.style.textAlign = "center";
    notification.style.maxWidth = "80%";

    document.body.appendChild(notification);

    setTimeout(() => {
      notification.style.opacity = "0";
      notification.style.transition = "opacity 0.5s ease";
      setTimeout(() => document.body.removeChild(notification), 500);
    }, duration);
  }

  // Hotkey mappings with functions - Updated per user preference
  const HOTKEYS = {
    h: navigateToHome, // i -> h for home
    s: navigateToSubscriptions, // i -> s for subscriptions
    e: navigateToHistory, // i -> e for history
    w: navigateToWatchLater, // i -> w for watch later
    l: navigateToLikedVideos, // i -> l for liked videos
    t: navigateToTrending, // i -> t for trending
    L: navigateToLibrary, // i -> L (capital) for library
    y: copyVideoUrlWithTimestamp, // i -> y for copy URL with timestamp
    v: navigateToChannelVideos, // i -> a for channel videos
    q: navigateToChannelPlaylists, // i -> q for channel playlists
    n: navigateToNextVideo, // i -> n for next video
    p: navigateToPreviousVideo, // i -> p for previous video
    Tab: toggleSidebar, // i -> Tab for toggle sidebar
    S: triggerSaveButton, // i -> s (capital) for Save to playlist popup
    Y: copyShortenedUrl, // i -> Y (capital) for shortened URL
    C: navigateToCommunityTab, // i -> C (capital) for community tab
    "?": showHelpModal,
  };

  // State variables
  let leaderPressed = false;
  let leaderTimer = null;

  // Function to handle keydown events
  function handleKeyDown(event) {
    // Check if user is typing in an input field
    if (isInputField(event.target)) {
      return;
    }

    // Get the key that was pressed (preserve case)
    const key = event.key;

    // If leader key is pressed
    if (key.toLowerCase() === LEADER_KEY) {
      // Prevent default action (like mini player)
      event.preventDefault();
      event.stopPropagation();

      // Set the leader state
      leaderPressed = true;

      // Clear any existing timer
      if (leaderTimer) {
        clearTimeout(leaderTimer);
      }

      // Set a timeout to reset the leader state
      leaderTimer = setTimeout(() => {
        leaderPressed = false;
      }, TIMEOUT);

      return;
    }

    // If a key is pressed after the leader key
    if (leaderPressed && HOTKEYS[key]) {
      // Prevent default action
      event.preventDefault();
      event.stopPropagation();

      // Execute the function associated with the key
      HOTKEYS[key]();

      // Reset leader state
      leaderPressed = false;
      clearTimeout(leaderTimer);
    }
  }

  // Helper function to check if the active element is an input field
  function isInputField(element) {
    const tagName = element.tagName.toLowerCase();
    const type = element.type ? element.type.toLowerCase() : "";

    return (
      (tagName === "input" &&
        (type === "text" ||
          type === "password" ||
          type === "email" ||
          type === "number" ||
          type === "search" ||
          type === "tel" ||
          type === "url")) ||
      tagName === "textarea" ||
      element.isContentEditable
    );
  }

  // Add event listener for keydown
  document.addEventListener("keydown", handleKeyDown, true);

  // Show initial notification about the leader key on first load
  const firstRun = GM_getValue("firstRun", true);
  if (firstRun) {
    setTimeout(() => {
      showNotification(
        `YouTube Leader Key Navigation activated! Leader key is '${LEADER_KEY}'`,
        5000,
      );
      GM_setValue("firstRun", false);
    }, 2000);
  }

  // Logging for debugging
  console.log(
    `YouTube Leader Key Navigation loaded with leader key '${LEADER_KEY}'`,
  );
})();