YouTube Custom Speed Control Button

Injects a custom speed control button into the YouTube player

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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);
  });
})();