Better Youtube Shorts

Provides more control features for Youtube Shorts, including volume control, progress bar, auto-scroll, hotkeys, and more.

Устаревшая версия за 12.04.2024. Перейдите к последней версии.

// ==UserScript==
// @name               Better Youtube Shorts
// @name:zh-CN         更好的 Youtube Shorts
// @name:zh-TW         更好的 Youtube Shorts
// @namespace          Violentmonkey Scripts
// @version            1.5.6
// @description        Provides more control features for Youtube Shorts, including volume control, progress bar, auto-scroll, hotkeys, and more.
// @description:zh-CN  为 Youtube Shorts提供更多的控制功能,包括音量控制,进度条,自动滚动,快捷键等等。
// @description:zh-TW  為 Youtube Shorts提供更多的控制功能,包括音量控制,進度條,自動滾動,快捷鍵等等。
// @author             Meriel
// @match              *://www.youtube.com/shorts/*
// @run-at             document-start
// @grant              GM_addStyle
// @grant              GM_getValue
// @grant              GM_setValue
// @grant              GM_registerMenuCommand
// @license            MIT
// @icon               https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==

(function () {
  GM_addStyle(
    `input[type="range"].volslider {
    height: 14px;
    -webkit-appearance: none;
    margin: 10px 0;
  }
  input[type="range"].volslider:focus {
    outline: none;
  }
  input[type="range"].volslider::-webkit-slider-runnable-track {
    height: 8px;
    cursor: pointer;
    box-shadow: 0px 0px 0px #000000;
    background: rgb(50 50 50);
    border-radius: 25px;
    border: 1px solid #000000;
  }
  input[type="range"].volslider::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 20px;
    height: 20px;
    margin-top: -7px;
    border-radius: 0px;
    background-image: url("https://i.imgur.com/vcQoCVS.png");
    background-size: 20px;
    background-repeat: no-repeat;
    background-position: 50%;
  }
  input[type="range"]:focus::-webkit-slider-runnable-track {
    background: rgb(50 50 50);
  }

  .switch {
    position: relative;
    display: inline-block;
    width: 46px;
    height: 20px;
  }
  .switch input {
    opacity: 0;
    width: 0;
    height: 0;
  }
  .slider {
    position: absolute;
    cursor: pointer;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: #ccc;
    -webkit-transition: 0.4s;
    transition: 0.4s;
  }
  .slider:before {
    position: absolute;
    content: "";
    height: 12px;
    width: 12px;
    left: 4px;
    bottom: 4px;
    background-color: white;
    -webkit-transition: 0.4s;
    transition: 0.4s;
  }
  input:checked + .slider {
    background-color: #ff0000;
  }
  input:focus + .slider {
    box-shadow: 0 0 1px #ff0000;
  }
  input:checked + .slider:before {
    -webkit-transform: translateX(26px);
    -ms-transform: translateX(26px);
    transform: translateX(26px);
  }
  /* Rounded sliders */
  .slider.round {
    border-radius: 12px;
  }
  .slider.round:before {
    border-radius: 50%;
  }`
  );

  let seekMouseDown = false;
  let lastCurSeconds = 0;
  let video = null;
  let audioInitialized = false;
  let autoScroll = GM_getValue("autoScroll", true);
  let volume = GM_getValue("volume", 0);
  let constantVolume = GM_getValue("constantVolume");
  let operationMode = GM_getValue("operationMode");
  if (constantVolume === void 0) {
    constantVolume = false;
    GM_setValue("constantVolume", constantVolume);
  }
  if (operationMode === void 0) {
    operationMode = "Shorts";
    GM_setValue("operationMode", operationMode);
  }

  GM_registerMenuCommand(
    `Constant Volume: ${constantVolume ? "On" : "Off"}`,
    function () {
      constantVolume = !constantVolume;
      GM_setValue("constantVolume", constantVolume);
      location.reload();
    }
  );

  GM_registerMenuCommand(`Operating mode: ${operationMode}`, function () {
    operationMode = operationMode === "Video" ? "Shorts" : "Video";
    GM_setValue("operationMode", operationMode);
    location.reload();
  });

  const observer = new MutationObserver(
    (mutations, shortsReady = false, videoPlayerReady = false) => {
      outer: for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (!shortsReady) {
            shortsReady = node.tagName === "YTD-SHORTS";
          }
          if (!videoPlayerReady) {
            videoPlayerReady =
              typeof node.className === "string" &&
              node.className.includes("html5-main-video");
          }
          if (shortsReady && videoPlayerReady) {
            observer.disconnect();
            video = node;
            if (constantVolume) {
              video.volume = volume;
            }
            addShortcuts();
            updateVidElemWithRAF();
            break outer;
          }
        }
      }
    }
  );
  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });

  function videoOperationMode(e) {
    if (!e.shiftKey) {
      if (
        e.key.toUpperCase() === "ARROWUP" ||
        e.key.toUpperCase() === "ARROWDOWN"
      ) {
        e.stopPropagation();
        e.preventDefault();
        const volumeSlider = document.querySelector("#byts-vol");
        switch (e.key.toUpperCase()) {
          case "ARROWUP":
            video.volume = Math.min(1, video.volume + 0.01);
            volumeSlider.value = video.volume;
            break;
          case "ARROWDOWN":
            video.volume = Math.max(0, video.volume - 0.01);
            volumeSlider.value = video.volume;
            break;
          default:
            break;
        }
      } else if (
        e.key.toUpperCase() === "ARROWLEFT" ||
        e.key.toUpperCase() === "ARROWRIGHT"
      ) {
        switch (e.key.toUpperCase()) {
          case "ARROWLEFT":
            video.currentTime -= 1;
            break;
          case "ARROWRIGHT":
            video.currentTime += 1;
            break;
          default:
            break;
        }
      }
    } else {
      switch (e.key.toUpperCase()) {
        case "ARROWLEFT":
        case "ARROWUP":
          navigationButtonUp();
          break;
        case "ARROWRIGHT":
        case "ARROWDOWN":
          navigationButtonDown();
          break;
        default:
          break;
      }
    }
  }

  function shortsOperationMode(e) {
    if (
      e.key.toUpperCase() === "ARROWUP" ||
      e.key.toUpperCase() === "ARROWDOWN"
    ) {
      e.preventDefault();
      e.stopPropagation();
      const volumeSlider = document.querySelector("#byts-vol");
      if (e.shiftKey) {
        switch (e.key.toUpperCase()) {
          case "ARROWUP":
            video.volume = Math.min(1, video.volume + 0.02);
            volumeSlider.value = video.volume;
            break;
          case "ARROWDOWN":
            video.volume = Math.max(0, video.volume - 0.02);
            volumeSlider.value = video.volume;
            break;
          default:
            break;
        }
      } else {
        switch (e.key.toUpperCase()) {
          case "ARROWUP":
            navigationButtonUp();
            break;
          case "ARROWDOWN":
            navigationButtonDown();
            break;
          default:
            break;
        }
      }
    } else if (
      e.key.toUpperCase() === "ARROWLEFT" ||
      e.key.toUpperCase() === "ARROWRIGHT"
    ) {
      const volumeSlider = document.querySelector("#byts-vol");
      if (e.shiftKey) {
        switch (e.key.toUpperCase()) {
          case "ARROWLEFT":
            video.volume = Math.max(0, video.volume - 0.01);
            volumeSlider.value = video.volume;
            break;
          case "ARROWRIGHT":
            video.volume = Math.min(1, video.volume + 0.01);
            volumeSlider.value = video.volume;
            break;
          default:
            break;
        }
      } else {
        switch (e.key.toUpperCase()) {
          case "ARROWLEFT":
            video.currentTime -= 1;
            break;
          case "ARROWRIGHT":
            video.currentTime += 1;
            break;
          default:
            break;
        }
      }
    }
  }

  function addShortcuts() {
    if (operationMode === "Video") {
      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
            if (node?.id === "byts-vol-div") {
              document.addEventListener(
                "keydown",
                (e) => {
                  videoOperationMode(e);
                  if (constantVolume) {
                    constantVolume = false;
                    requestAnimationFrame(() => (constantVolume = true));
                  }
                },
                {
                  capture: true,
                }
              );
              observer.disconnect();
            }
          }
        }
      });
      observer.observe(document.documentElement, {
        childList: true,
        subtree: true,
      });
    } else {
      document.addEventListener(
        "keydown",
        (e) => {
          shortsOperationMode(e);
          if (constantVolume) {
            constantVolume = false;
            requestAnimationFrame(() => (constantVolume = true));
          }
        },
        {
          capture: true,
        }
      );
    }
    video.addEventListener("dblclick", () => {
      if (document.fullscreenElement) {
        document.exitFullscreen();
      } else {
        document.querySelector("ytd-app").requestFullscreen();
      }
    });
    document.addEventListener("keydown", (e) => {
      if (
        e.key.toUpperCase() === "ENTER" ||
        e.key.toUpperCase() === "NUMPADENTER"
      ) {
        if (document.fullscreenElement) {
          document.exitFullscreen();
        } else {
          document.querySelector("ytd-app").requestFullscreen();
        }
      }
    });
    document.addEventListener("keydown", (e) => {
      if (e.key.toUpperCase() === "M") {
        video.muted = !video.muted;
      }
    });
  }

  function padTo2Digits(num) {
    return num.toString().padStart(2, "0");
  }

  function updateVidElemWithRAF() {
    try {
      updateVidElem();
    } catch (_) {}
    requestAnimationFrame(updateVidElemWithRAF);
  }

  function navigationButtonDown() {
    document.querySelector("#navigation-button-down button").click();
  }

  function navigationButtonUp() {
    document.querySelector("#navigation-button-up button").click();
  }

  function setVideoPlaybackTime(event, player) {
    let rect = player.getBoundingClientRect();
    let offsetX = event.clientX - rect.left;
    if (offsetX < 0) {
      offsetX = 0;
    } else if (offsetX > player.offsetWidth) {
      offsetX = player.offsetWidth - 1;
    }
    video.currentTime = (offsetX / player.offsetWidth) * video.duration;
  }

  function updateVidElem() {
    if (
      video !==
      document.querySelector(
        "#shorts-player > div.html5-video-container > video"
      )
    ) {
      video = document.querySelector(
        "#shorts-player > div.html5-video-container > video"
      );
    }

    if (!audioInitialized && constantVolume) {
      video.volume = volume;
    }

    const reel = document.querySelector("ytd-reel-video-renderer[is-active]");
    if (reel === null) {
      return;
    }

    if (operationMode === "Shorts") {
      document.removeEventListener("keydown", videoOperationMode, {
        capture: true,
      });
      document.addEventListener("keydown", shortsOperationMode, {});
    } else {
      document.removeEventListener("keydown", shortsOperationMode, {});
      document.addEventListener("keydown", videoOperationMode, {
        capture: true,
      });
    }

    // Volume Slider
    let volumeSliderDiv = document.querySelector("#byts-vol-div");
    let volumeSlider = document.querySelector("#byts-vol");
    let volumeTextDiv = document.querySelector("#byts-vol-textdiv");
    if (reel.querySelector("#byts-vol-div") === null) {
      if (volumeSliderDiv === null) {
        volumeSliderDiv = document.createElement("div");
        volumeSliderDiv.id = "byts-vol-div";
        volumeSliderDiv.style.cssText = `user-select: none; width: 100px; left: 0px; background-color: transparent; position: absolute; margin-left: 5px; margin-top: ${
          reel.offsetHeight + 5
        }px;`;
        volumeSlider = document.createElement("input");
        volumeSlider.style.cssText = `user-select: none; width: 80px; left: 0px; background-color: transparent; position: absolute; margin-top: 0px;`;
        volumeSlider.type = "range";
        volumeSlider.id = "byts-vol";
        volumeSlider.className = "volslider";
        volumeSlider.name = "vol";
        volumeSlider.min = 0.0;
        volumeSlider.max = 1.0;
        volumeSlider.step = 0.01;
        volumeSlider.value = video.volume;
        volumeSlider.addEventListener("input", function () {
          video.volume = this.value;
          GM_setValue("volume", this.value);
        });
        volumeSliderDiv.appendChild(volumeSlider);
        volumeTextDiv = document.createElement("div");
        volumeTextDiv.id = "byts-vol-textdiv";
        volumeTextDiv.style.cssText = `user-select: none; background-color: transparent; position: absolute; color: white; font-size: 1.2rem; margin-left: ${
          volumeSlider.offsetWidth + 5
        }px`;
        volumeTextDiv.textContent = `${(
          video.volume.toFixed(2) * 100
        ).toFixed()}%`;
        volumeSliderDiv.appendChild(volumeTextDiv);
      }
      reel.appendChild(volumeSliderDiv);
      audioInitialized = true;
    }
    if (constantVolume) {
      video.volume = volumeSlider.value;
    }
    volumeSlider.value = video.volume;
    volumeTextDiv.textContent = `${(video.volume.toFixed(2) * 100).toFixed()}%`;
    volumeSliderDiv.style.marginTop = `${reel.offsetHeight + 5}px`;
    volumeTextDiv.style.marginLeft = `${volumeSlider.offsetWidth + 5}px`;

    // Progress Bar
    let progressBar = document.querySelector("#byts-progbar");
    if (reel.querySelector("#byts-progbar") === null) {
      const builtinProgressbar = reel.querySelector("#progress-bar");
      if (builtinProgressbar !== null) {
        builtinProgressbar.remove();
      }
      if (progressBar === null) {
        progressBar = document.createElement("div");
        progressBar.id = "byts-progbar";
        progressBar.style.cssText = `user-select: none; cursor: pointer; width: 98%; height: 6px; background-color: #343434; position: absolute; border-radius: 10px; margin-top: ${
          reel.offsetHeight - 6
        }px;`;
      }
      reel.appendChild(progressBar);

      let wasPausedBeforeDrag = false;
      progressBar.addEventListener("mousedown", (e) => {
        seekMouseDown = true;
        wasPausedBeforeDrag = video.paused;
        setVideoPlaybackTime(e, progressBar);
        video.pause();
      });
      document.addEventListener("mousemove", (e) => {
        if (!seekMouseDown) return;
        setVideoPlaybackTime(e, progressBar);
        if (!video.paused) {
          video.pause();
        }
      });
      document.addEventListener("mouseup", () => {
        if (!seekMouseDown) return;
        seekMouseDown = false;
        if (!wasPausedBeforeDrag) {
          video.play();
        }
      });
    }
    progressBar.style.marginTop = `${reel.offsetHeight - 6}px`;

    // Progress Bar (Inner Red Bar)
    let progressTime = (video.currentTime / video.duration) * 100;
    let InnerProgressBar = progressBar.querySelector("#byts-progress");
    if (InnerProgressBar === null) {
      InnerProgressBar = document.createElement("div");
      InnerProgressBar.id = "byts-progress";
      InnerProgressBar.style.cssText = `user-select: none; background-color: #FF0000; height: 100%; border-radius: 10px; width: ${progressTime}%;`;
      progressBar.appendChild(InnerProgressBar);
    }
    InnerProgressBar.style.width = `${progressTime}%`;

    // Time Info
    let durSecs = Math.floor(video.duration);
    let durMinutes = Math.floor(durSecs / 60);
    let durSeconds = durSecs % 60;
    let curSecs = Math.floor(video.currentTime);

    let timeInfo = document.querySelector("#byts-timeinfo");
    let timeInfoText = document.querySelector("#byts-timeinfo-textdiv");

    if (
      !Number.isNaN(durSecs) &&
      reel.querySelector("#byts-timeinfo") !== null
    ) {
      timeInfoText.textContent = `${Math.floor(curSecs / 60)}:${padTo2Digits(
        curSecs % 60
      )} / ${durMinutes}:${padTo2Digits(durSeconds)}`;
    }
    if (
      curSecs !== lastCurSeconds ||
      reel.querySelector("#byts-timeinfo") === null
    ) {
      lastCurSeconds = curSecs;
      let curMinutes = Math.floor(curSecs / 60);
      let curSeconds = curSecs % 60;

      if (reel.querySelector("#byts-timeinfo") === null) {
        if (timeInfo === null) {
          timeInfo = document.createElement("div");
          timeInfo.id = "byts-timeinfo";
          timeInfo.style.cssText = `user-select: none; display: flex; right: auto; left: auto; position: absolute; margin-top: ${
            reel.offsetHeight + 2
          }px;`;
          timeInfoText = document.createElement("div");
          timeInfoText.id = "byts-timeinfo-textdiv";
          timeInfoText.style.cssText = `display: flex; margin-right: 5px; margin-top: 4px; color: white; font-size: 1.2rem;`;
          timeInfoText.textContent = `${curMinutes}:${padTo2Digits(
            curSeconds
          )} / ${durMinutes}:${padTo2Digits(durSeconds)}`;
          timeInfo.appendChild(timeInfoText);
        }
        reel.appendChild(timeInfo);
        timeInfoText.textContent = `${curMinutes}:${padTo2Digits(
          curSeconds
        )} / ${durMinutes}:${padTo2Digits(durSeconds)}`;
      }
    }
    timeInfo.style.marginTop = `${reel.offsetHeight + 2}px`;

    // AutoScroll
    let autoScrollDiv = document.querySelector("#byts-autoscroll-div");
    if (reel.querySelector("#byts-autoscroll-div") === null) {
      if (autoScrollDiv === null) {
        autoScrollDiv = document.createElement("div");
        autoScrollDiv.id = "byts-autoscroll-div";
        autoScrollDiv.style.cssText = `user-select: none; display: flex; right: 0px; position: absolute; margin-top: ${
          reel.offsetHeight + 2
        }px;`;
        const autoScrollTextDiv = document.createElement("div");
        autoScrollTextDiv.style.cssText = `display: flex; margin-right: 5px; margin-top: 4px; color: white; font-size: 1.2rem;`;
        autoScrollTextDiv.textContent = "Auto Scroll: ";
        autoScrollDiv.appendChild(autoScrollTextDiv);
        const autoScrollSwitch = document.createElement("label");
        autoScrollSwitch.className = "switch";
        const autoscrollInput = document.createElement("input");
        autoscrollInput.id = "byts-autoscroll-input";
        autoscrollInput.type = "checkbox";
        autoscrollInput.checked = autoScroll;
        autoscrollInput.addEventListener("input", function () {
          autoScroll = this.checked;
          GM_setValue("autoScroll", this.checked);
        });
        const autoScrollSlider = document.createElement("span");
        autoScrollSlider.className = "slider round";
        autoScrollSwitch.appendChild(autoscrollInput);
        autoScrollSwitch.appendChild(autoScrollSlider);
        autoScrollDiv.appendChild(autoScrollSwitch);
      }
      reel.appendChild(autoScrollDiv);
    }
    if (autoScroll === true) {
      video.removeAttribute("loop");
      video.removeEventListener("ended", navigationButtonDown);
      video.addEventListener("ended", navigationButtonDown);
    } else {
      video.setAttribute("loop", true);
      video.removeEventListener("ended", navigationButtonDown);
    }
    autoScrollDiv.style.marginTop = `${reel.offsetHeight + 2}px`;
  }
})();