YouTube Embed Enhancer

Enhances YouTube Embeds with custom volume controls, hotkeys, and some optimizations.

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

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

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube Embed Enhancer
// @namespace    https://github.com/jmpatag
// @version      1.4.0
// @description  Enhances YouTube Embeds with custom volume controls, hotkeys, and some optimizations.
// @author       jmpatag
// @license      MIT
// @match        *://www.youtube.com/embed/*
// @match        *://www.youtube-nocookie.com/embed/*
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";


  // Inject custom controls tightly if natively disabled via URL parameters (controls=0),
  // or as a fallback if the standard player UI simply fails to render into the DOM.
  const isControlsDisabled = new URLSearchParams(window.location.search).get("controls") === "0";
  const isPlayButtonMissing = !document.querySelector(".ytp-play-button");

  if (isControlsDisabled || isPlayButtonMissing) {
    document.head.appendChild(
      Object.assign(document.createElement("style"), {
        textContent: `
          player-fullscreen-action-menu {
            display: none !important;
          }

          #custom-vol-overlay,
          #custom-speed-overlay {
            position: fixed;
            top: 140px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 10px 20px;
            border-radius: 6px;
            font-size: 18px;
            font-family: sans-serif;
            font-weight: bold;
            z-index: 9999;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s;
            will-change: opacity;
          }

          #custom-vol-overlay.show,
          #custom-speed-overlay.show {
            opacity: 1;
            transition: opacity 0.1s;
          }

          #custom-speed-overlay {
            top: 190px;
          }


          #custom-mute-btn,
          #custom-vol-slider,
          #custom-stats-btn,
          #custom-speed-btn {
            z-index: 9999;
            position: fixed;

            color: white;
            line-height: 1;
            text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
            background: none;
            border: none;
          }

          #custom-stats-btn,
          #custom-speed-btn {
            bottom: 18px;

            padding: 5px 12px;
            font-size: 13px;
            font-family: monospace;
            font-weight: bold;
            letter-spacing: 0.04em;
            color: rgba(255, 255, 255, 0.9);
            background: rgba(0, 0, 0, 0.5) !important;
            border: 1px solid rgba(255, 255, 255, 0.35) !important;
            border-radius: 5px;

            cursor: pointer;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s, background 0.15s, transform 0.1s;
            will-change: opacity;
          }

          #custom-stats-btn {
            right: 18px;
          }

          #custom-speed-btn {
            right: 80px;
          }

          #custom-stats-btn.show,
          #custom-speed-btn.show {
            opacity: 1;
            pointer-events: auto;
          }

          #custom-stats-btn.show:hover,
          #custom-speed-btn.show:hover {
            background: rgba(255, 255, 255, 0.18) !important;
            transform: scale(1.05);
          }

          #custom-stats-btn.active {
            color: #ffd700;
            border-color: rgba(255, 215, 0, 0.6) !important;
            background: rgba(255, 215, 0, 0.12) !important;
          }

          #custom-speed-btn.modified {
            color: #7df;
            border-color: rgba(119, 221, 255, 0.6) !important;
            background: rgba(119, 221, 255, 0.1) !important;
          }

          #custom-mute-btn {
            bottom: 18px;
            left: 18px;

            width: 28px;
            height: 28px;

            cursor: pointer;
            background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>') no-repeat center center;
            background-size: contain;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s, transform 0.1s;
            will-change: opacity;
          }

          #custom-mute-btn.show {
            opacity: 1;
            pointer-events: auto;
          }

          #custom-mute-btn.show:hover {
            transform: scale(1.1);
          }

          #custom-mute-btn.muted {
            background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>') no-repeat center center;
            background-size: contain;
          }

          #custom-vol-slider {
            bottom: 23px;
            left: 55px;

            width: 85px;
            height: 12px;

            cursor: pointer;
            appearance: none;
            background: transparent;
            outline: none;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s, width 0.2s;
            will-change: opacity;
          }

          #custom-vol-slider.show {
            opacity: 0.8;
            pointer-events: auto;
          }

          #custom-vol-slider.show:hover {
            opacity: 1;
            width: 100px;
          }

          #custom-vol-slider::-webkit-slider-runnable-track {
            height: 4px;
            border-radius: 2px;
            background: rgba(255, 255, 255, 0.4);
          }

          #custom-vol-slider::-moz-range-track {
            height: 4px;
            border-radius: 2px;
            background: rgba(255, 255, 255, 0.4);
          }

          #custom-vol-slider::-webkit-slider-thumb {
            width: 12px;
            height: 12px;
            margin-top: -4px;

            appearance: none;
            border-radius: 50%;
            background: white;
            box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
          }

          #custom-vol-slider::-moz-range-thumb {
            width: 12px;
            height: 12px;

            border-radius: 50%;
            background: white;
            border: none;
            box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
          }
        `,
      }),
    );


    const video = document.querySelector("video");

    let targetVolume = video.volume;
    let targetMuted = video.muted;

    const originalVolume = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'volume');
    const originalMuted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
    let isScriptChange = false;

    Object.defineProperty(video, 'volume', {
      get() { return originalVolume.get.call(this); },
      set(newVol) {
        if (!isScriptChange) return;
        originalVolume.set.call(this, newVol);
      }
    });

    Object.defineProperty(video, 'muted', {
      get() { return originalMuted.get.call(this); },
      set(newMuted) {
        if (!isScriptChange) return;
        originalMuted.set.call(this, newMuted);
      }
    });

    const setVideoVolume = (v) => { isScriptChange = true; video.volume = v; isScriptChange = false; };
    const setVideoMuted = (m) => { isScriptChange = true; video.muted = m; isScriptChange = false; };

    // Apply a volume change using targetVolume as the source of truth.
    const applyVolume = (newVol) => {
      targetVolume = Math.min(1, Math.max(0, Math.round(newVol * 100) / 100));
      targetMuted = targetVolume === 0;
      // Single muted call covers both mute and unmute.
      setVideoMuted(targetMuted);
      setVideoVolume(targetVolume);
      showVolumePercent(targetMuted ? 0 : targetVolume);
    };

    // Shared mute toggle — avoids a synthetic DOM click event.
    const toggleMute = () => {
      targetMuted = !video.muted;
      setVideoMuted(targetMuted);
      muteBtn.classList.toggle("muted", video.muted);
      showVolumePercent(video.muted ? 0 : targetVolume);
    };

    // Volume overlay UI
    const volPct = Object.assign(document.createElement("div"), {
      id: "custom-vol-overlay"
    });
    let volTimeout;
    const showVolumePercent = (volume) => {
      volPct.textContent = Math.round(volume * 100) + "%";
      volPct.classList.add("show");
      clearTimeout(volTimeout);
      volTimeout = setTimeout(() => volPct.classList.remove("show"), 1500);
    };

    // Speed overlay UI
    const speedOverlay = Object.assign(document.createElement("div"), {
      id: "custom-speed-overlay"
    });
    let speedTimeout;
    const showSpeedOverlay = (rate) => {
      speedOverlay.textContent = rate + "x";
      speedOverlay.classList.add("show");
      clearTimeout(speedTimeout);
      speedTimeout = setTimeout(() => speedOverlay.classList.remove("show"), 1500);
    };


    // Mute button.
    const muteBtn = Object.assign(document.createElement("button"), {
      id: "custom-mute-btn",
    });

    muteBtn.addEventListener("click", toggleMute);

    // Volume slider.
    const vol = Object.assign(document.createElement("input"), {
      id: "custom-vol-slider",
      type: "range",
      min: 0,
      max: 1,
      step: 0.01,
      value: video.volume,
    });

    vol.addEventListener("input", () => {
      applyVolume(Number(vol.value));
    });

    // Sync slider and mute icon on any external volumechange.
    // Guard with isScriptChange so our own writes don't trigger unnecessary DOM updates.
    video.addEventListener("volumechange", () => {
      if (isScriptChange) return;
      muteBtn.classList.toggle("muted", video.muted);
      vol.value = video.muted ? 0 : video.volume;
    });

    // Mouse wheel: scroll on speed button → change speed; anywhere else → change volume.
    let isHoveringSpeedBtn = false;

    window.addEventListener("wheel", (e) => {
      e.preventDefault();
      if (isHoveringSpeedBtn) {
        applySpeed(speedIndex + (e.deltaY > 0 ? -1 : 1));
      } else {
        applyVolume(targetVolume + (e.deltaY > 0 ? -0.05 : 0.05));
      }
    }, { passive: false });

    // Stats for nerds toggle (Shift + S or stats button)
    let isStatsOpen = false;

    // Cache player element once — it never changes after load.
    const player = document.getElementById("movie_player") || document.querySelector(".html5-video-player");

    const statsBtn = Object.assign(document.createElement("button"), {
      id: "custom-stats-btn",
      title: "Stats for Nerds (Shift+S)",
      textContent: "Stats",
    });

    const toggleStats = () => {
      if (!player) return;
      if (isStatsOpen && player.hideVideoInfo) {
        player.hideVideoInfo();
        isStatsOpen = false;
      } else if (player.showVideoInfo) {
        player.showVideoInfo();
        isStatsOpen = true;
      }
      statsBtn.classList.toggle("active", isStatsOpen);
    };

    statsBtn.addEventListener("click", toggleStats);

    // Playback speed (< / > keys, or speed button)
    const SPEED_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
    const SPEED_LAST_INDEX = SPEED_STEPS.length - 1;
    const SPEED_DEFAULT_INDEX = 3; // index of 1x — compile-time constant
    let speedIndex = SPEED_DEFAULT_INDEX;

    const speedBtn = Object.assign(document.createElement("button"), {
      id: "custom-speed-btn",
      title: "Playback Speed (< slower / > faster) or scroll wheel",
      textContent: "1x",
    });

    const applySpeed = (index) => {
      speedIndex = Math.max(0, Math.min(SPEED_LAST_INDEX, index));
      const rate = SPEED_STEPS[speedIndex];
      video.playbackRate = rate;
      speedBtn.textContent = rate + "x";
      speedBtn.classList.toggle("modified", rate !== 1);
      showSpeedOverlay(rate);
    };

    speedBtn.addEventListener("click", () => {
      // Cycle forward; wrap back to 0 after the last step
      applySpeed(speedIndex < SPEED_LAST_INDEX ? speedIndex + 1 : 0);
    });

    // Reset to 1x on right-click
    speedBtn.addEventListener("contextmenu", (e) => {
      e.preventDefault();
      applySpeed(SPEED_DEFAULT_INDEX);
    });

    // Track hover so the wheel handler knows which mode to use
    speedBtn.addEventListener("mouseenter", () => { isHoveringSpeedBtn = true; });
    speedBtn.addEventListener("mouseleave", () => { isHoveringSpeedBtn = false; });

    // Merged keydown handler — one listener, covers all shortcuts + triggers showControls.
    window.addEventListener("keydown", (e) => {
      switch (e.key) {
        case "ArrowUp":
        case "ArrowDown":
          e.stopImmediatePropagation();
          e.preventDefault();
          applyVolume(targetVolume + (e.key === "ArrowUp" ? 0.05 : -0.05));
          break;
        case "S":
          if (e.shiftKey) {
            e.stopImmediatePropagation();
            e.preventDefault();
            toggleStats();
          }
          break;
        case ">":
        case ".":
          e.stopImmediatePropagation();
          e.preventDefault();
          applySpeed(speedIndex + 1);
          break;
        case "<":
        case ",":
          e.stopImmediatePropagation();
          e.preventDefault();
          applySpeed(speedIndex - 1);
          break;
        case "m":
        case "M":
          e.stopImmediatePropagation();
          e.preventDefault();
          toggleMute();
          break;
      }
      // Always show controls on any keypress (passive keydown listener removed).
      showControls();
    }, true);

    // Auto-hide controls logic.
    // mousemove throttle: only reset the hide-timer at most once per animation frame.
    const ALL_CONTROLS = [muteBtn, vol, statsBtn, speedBtn];
    let controlsTimeout;
    let controlsVisible = false;
    let rafPending = false;

    const hideControls = () => {
      controlsVisible = false;
      ALL_CONTROLS.forEach(el => el.classList.remove("show"));
    };
    const showControls = () => {
      if (!controlsVisible) {
        controlsVisible = true;
        ALL_CONTROLS.forEach(el => el.classList.add("show"));
      }
      clearTimeout(controlsTimeout);
      controlsTimeout = setTimeout(hideControls, 2000);
    };
    const onMouseMove = () => {
      if (rafPending) return;
      rafPending = true;
      requestAnimationFrame(() => {
        rafPending = false;
        showControls();
      });
    };

    window.addEventListener("mousemove", onMouseMove);
    // wheel already calls showControls() inline via the wheel handler above.
    showControls(); // Show immediately on load

    // Double click to fullscreen
    window.addEventListener("dblclick", (e) => {
      if (e.target.closest("#custom-mute-btn, #custom-vol-slider, #custom-stats-btn, #custom-speed-btn")) return;

      if (!document.fullscreenElement) {
        document.documentElement.requestFullscreen().catch(() => { });
      } else if (document.exitFullscreen) {
        document.exitFullscreen();
      }
    });

    // Add all elements.
    document.body.prepend(volPct, speedOverlay, vol, muteBtn, statsBtn, speedBtn);


  }
})();