YouTube Custom Speed Control Button

Injects a custom speed control button into the YouTube player

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube Custom Speed Control Button
// @namespace    http://tampermonkey.net/
// @version      2026-04-30
// @description  Injects a custom speed control button into the YouTube player
// @author       You
// @match        *://*.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const trustedPolicy =
    window.trustedTypes && window.trustedTypes.createPolicy
      ? window.trustedTypes.createPolicy("youtube-speed-btn-policy", {
          createHTML: (string) => string,
        })
      : null;

  function injectSpeedButton() {
    const buttonId = "custom-speed-btn";

    // Prevent duplicate buttons
    if (document.getElementById(buttonId)) return;

    const rightControlsLeft = document.querySelector(
      ".ytp-right-controls .ytp-right-controls-left",
    );
    if (!rightControlsLeft) return;

    const getCurrentVideo = () =>
      document.querySelector("video.html5-main-video") ??
      document.querySelector("video");
    if (!getCurrentVideo()) return;

    // Create the button
    const btn = document.createElement("button");
    btn.id = buttonId;
    btn.className = "ytp-button"; // Uses YouTube's native button class

    // // Add an SVG icon for the button
    const svg1xSpeedString = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.552 15.853q.714-.731.659-1.687t-.824-1.575a51 51 0 0 0-4.504-3.235A164 164 0 0 1 4.296 6.32a338 338 0 0 1 2.939 4.71 98 98 0 0 0 3.076 4.712q.55.815 1.538.83.99.014 1.703-.718M4.46 21q-.605 0-1.113-.267a2 2 0 0 1-.81-.802q-.77-1.35-1.153-2.826A12 12 0 0 1 1 14.08q.028-1.575.508-3.065.48-1.491 1.332-2.841l1.319 2.137q-.468.9-.715 1.87a8 8 0 0 0-.247 1.955q0 1.238.316 2.405t.948 2.208h15.133a9.4 9.4 0 0 0 .893-2.152q.316-1.139.316-2.348 0-3.74-2.568-6.37t-6.221-2.63q-1.017 0-1.991.253a9 9 0 0 0-1.854.703L6.08 4.856q1.318-.9 2.815-1.378A10.2 10.2 0 0 1 12.014 3q2.28 0 4.27.886 1.991.886 3.489 2.419a11.5 11.5 0 0 1 2.362 3.572Q23 11.916 23 14.25q0 1.518-.384 2.953a12 12 0 0 1-1.1 2.728 1.96 1.96 0 0 1-.823.802 2.4 2.4 0 0 1-1.099.267z" fill="#fff" /></svg>`;
    const svg2xSpeedString = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.416 21q-.606 0-1.115-.267a2 2 0 0 1-.812-.802 11.4 11.4 0 0 1-1.13-2.728 10.6 10.6 0 0 1-.357-2.953q0-2.644 1.142-4.992a11.1 11.1 0 0 1 3.208-3.952l.578 2.447a8.6 8.6 0 0 0-2.024 2.953 9.1 9.1 0 0 0-.702 3.544q0 1.181.303 2.334t.909 2.166h15.17q.579-1.04.895-2.18t.316-2.32q0-3.768-2.56-6.384t-6.25-2.616q-.275 0-.537.014-.261.015-.537.07L9.454 3.31q.634-.14 1.267-.225a10.53 10.53 0 0 1 5.561.802A11.1 11.1 0 0 1 19.78 6.29a11.4 11.4 0 0 1 2.354 3.572Q23 11.915 23 14.25q0 1.518-.385 2.94-.385 1.42-1.102 2.741a2 2 0 0 1-.812.802q-.51.267-1.115.267zm8.48-4.725a2.27 2.27 0 0 0 1.266-1.378q.33-.957-.22-1.772a191 191 0 0 0-3.235-4.584Q9.069 6.29 7.334 4.069a120 120 0 0 0 1.17 5.512 109 109 0 0 0 1.446 5.457q.274.927 1.17 1.28a2.2 2.2 0 0 0 1.776-.043" fill="#fff"/></svg>`;
    const svg3xSpeedString = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.414 21q-.606 0-1.115-.267a2 2 0 0 1-.812-.802 12.8 12.8 0 0 1-1.102-2.742A11.2 11.2 0 0 1 1 14.25q0-2.335.867-4.387A11.4 11.4 0 0 1 4.221 6.29a11.1 11.1 0 0 1 3.497-2.405 10.53 10.53 0 0 1 5.561-.802q.634.086 1.267.225l-1.46 2.025a4 4 0 0 0-.536-.07q-.263-.014-.537-.014-3.69 0-6.25 2.616t-2.56 6.384q0 1.18.316 2.32.317 1.14.895 2.18h15.17a8.3 8.3 0 0 0 .909-2.166 9.2 9.2 0 0 0 .303-2.334 9.1 9.1 0 0 0-.703-3.544 8.6 8.6 0 0 0-2.023-2.953l.578-2.447a11.1 11.1 0 0 1 3.208 3.952 11.3 11.3 0 0 1 1.142 4.992 10.6 10.6 0 0 1-.358 2.953 11.4 11.4 0 0 1-1.129 2.728 2 2 0 0 1-.812.802q-.51.267-1.115.267zm6.69-4.725a2.2 2.2 0 0 0 1.776.042q.896-.35 1.17-1.28a109 109 0 0 0 1.446-5.456q.647-2.727 1.17-5.512a167 167 0 0 0-3.373 4.472q-1.638 2.25-3.235 4.584-.55.816-.22 1.772a2.27 2.27 0 0 0 1.266 1.378" fill="#fff"/></svg>`;
    const svg4xSpeedString = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 16.598q1.044-.014 1.539-.773l6.16-9.45-9.24 6.3q-.742.506-.783 1.547t.618 1.716 1.705.66M12 3q1.62 0 3.12.464t2.819 1.392l-2.09 1.35a8.2 8.2 0 0 0-3.85-.956q-3.657 0-6.228 2.63T3.2 14.25q0 1.181.316 2.334t.894 2.166h15.179a8.3 8.3 0 0 0 .92-2.222 9.8 9.8 0 0 0 .29-2.39q0-1.013-.234-1.97a8.3 8.3 0 0 0-.702-1.855l1.32-2.138q.826 1.322 1.306 2.813.48 1.49.51 3.093.027 1.603-.358 3.066a11.8 11.8 0 0 1-1.128 2.784q-.301.507-.825.788a2.3 2.3 0 0 1-1.1.281H4.41q-.578 0-1.1-.281a2.1 2.1 0 0 1-.825-.788A11.4 11.4 0 0 1 1 14.25q0-2.334.866-4.373a11.5 11.5 0 0 1 2.365-3.572q1.5-1.533 3.506-2.42A10.4 10.4 0 0 1 11.999 3" fill="#fff"/></svg>`;

    const getSpeedConfig = (speed) => {
      if (speed < 2.0) {
        return { icon: svg1xSpeedString, nextSpeed: 2.0 };
      }
      if (speed < 3.0) {
        return { icon: svg2xSpeedString, nextSpeed: 3.0 };
      }
      if (speed < 4.0) {
        return { icon: svg3xSpeedString, nextSpeed: 4.0 };
      }
      return { icon: svg4xSpeedString, nextSpeed: 1.0 };
    };

    const updateButtonState = () => {
      const currentVideo = getCurrentVideo();
      if (!currentVideo) return;

      const { icon, nextSpeed } = getSpeedConfig(currentVideo.playbackRate);
      const tooltip = `Play at ${nextSpeed.toFixed(1)}x speed`;
      btn.setAttribute("title", tooltip);
      btn.setAttribute("data-tooltip-title", tooltip);
      btn.setAttribute("data-title-no-tooltip", tooltip);
      btn.ariaLabel = tooltip;
      btn.innerHTML = trustedPolicy ? trustedPolicy.createHTML(icon) : icon;
    };

    updateButtonState();

    // The core logic
    btn.addEventListener("click", () => {
      const currentVideo = getCurrentVideo();
      if (currentVideo) {
        const { nextSpeed } = getSpeedConfig(currentVideo.playbackRate);
        console.log(
          `Changing speed from ${currentVideo.playbackRate} to ${nextSpeed}`,
        );
        currentVideo.playbackRate = nextSpeed;
        updateButtonState();
        setTimeout(updateButtonState, 150);
      }
    });

    const listenerController = new AbortController();
    document.addEventListener("ratechange", updateButtonState, {
      capture: true,
      signal: listenerController.signal,
    });
    document.addEventListener("loadedmetadata", updateButtonState, {
      capture: true,
      signal: listenerController.signal,
    });

    const cleanupObserver = new MutationObserver(() => {
      if (!document.body.contains(btn)) {
        listenerController.abort();
        cleanupObserver.disconnect();
      }
    });
    cleanupObserver.observe(document.body, { childList: true, subtree: true });

    // Insert the button at the beginning of the right controls
    rightControlsLeft.append(btn);
  }

  // Run once on initial page load
  injectSpeedButton();

  // Run every time YouTube finishes an in-page navigation
  window.addEventListener("yt-navigate-finish", () => {
    // Add a slight delay to ensure the video player DOM has caught up with the data layer
    setTimeout(injectSpeedButton, 500);
  });
})();