Video Control

Adjust HTML5 video playback speed and volume on webpages.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Video Control
// @namespace    https://example.local/video-control
// @version      1.2.0
// @description  Adjust HTML5 video playback speed and volume on webpages.
// @author       local
// @match        http://*/*
// @match        https://*/*
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
  "use strict";

  const DEFAULT_SETTINGS = Object.freeze({
    speed: 1,
    volume: 100,
    collapsed: false,
    antiPause: true,
    siteMemory: false,
    siteProfiles: {}
  });

  const LIMITS = Object.freeze({
    minSpeed: 0.5,
    maxSpeed: 5,
    minVolume: 0,
    maxVolume: 100
  });

  const STORAGE_KEY = "video-control-settings";
  const BOOTSTRAP_INTERVAL_MS = 1000;
  const RESUME_WINDOW_MS = 8000;
  const controlledVideos = new WeakSet();
  const videoStates = new WeakMap();
  let settings = loadSettings();
  let host = null;
  let shadow = null;
  let ui = {};
  let pageIsBlurred = false;
  let lastUrl = window.location.href;
  let lastUserActivationAt = 0;
  let bootstrapTimer = null;
  let settingsSnapshot = "";

  function isTopFrame() {
    try {
      return window.top === window;
    } catch (error) {
      return true;
    }
  }

  function getSiteKey() {
    let hostname = "";

    if (!isTopFrame() && document.referrer) {
      try {
        hostname = new URL(document.referrer).hostname;
      } catch (error) {
        hostname = "";
      }
    }

    if (!hostname) {
      hostname = window.location.hostname;
    }

    return String(hostname || "local")
      .toLowerCase()
      .replace(/^www\./, "");
  }

  function isRecord(value) {
    return Boolean(value && typeof value === "object" && !Array.isArray(value));
  }

  function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  function normalizeSettings(nextSettings) {
    const speed = Number(nextSettings && nextSettings.speed);
    const volume = Number(nextSettings && nextSettings.volume);
    const siteProfiles = isRecord(nextSettings && nextSettings.siteProfiles)
      ? { ...nextSettings.siteProfiles }
      : {};

    return {
      speed: clamp(Number.isFinite(speed) ? speed : DEFAULT_SETTINGS.speed, LIMITS.minSpeed, LIMITS.maxSpeed),
      volume: clamp(Number.isFinite(volume) ? Math.round(volume) : DEFAULT_SETTINGS.volume, LIMITS.minVolume, LIMITS.maxVolume),
      collapsed: Boolean(nextSettings && nextSettings.collapsed),
      antiPause: nextSettings && Object.prototype.hasOwnProperty.call(nextSettings, "antiPause")
        ? Boolean(nextSettings.antiPause)
        : DEFAULT_SETTINGS.antiPause,
      siteMemory: Boolean(nextSettings && nextSettings.siteMemory),
      siteProfiles
    };
  }

  function normalizeProfile(profile, fallback) {
    const speed = Number(profile && profile.speed);
    const volume = Number(profile && profile.volume);

    return {
      speed: clamp(Number.isFinite(speed) ? speed : fallback.speed, LIMITS.minSpeed, LIMITS.maxSpeed),
      volume: clamp(Number.isFinite(volume) ? Math.round(volume) : fallback.volume, LIMITS.minVolume, LIMITS.maxVolume),
      antiPause: profile && Object.prototype.hasOwnProperty.call(profile, "antiPause")
        ? Boolean(profile.antiPause)
        : fallback.antiPause
    };
  }

  function applySiteProfile(baseSettings) {
    const normalized = normalizeSettings(baseSettings);

    if (!normalized.siteMemory) {
      return normalized;
    }

    const profile = normalized.siteProfiles[getSiteKey()];

    if (!profile) {
      return normalized;
    }

    return {
      ...normalized,
      ...normalizeProfile(profile, normalized)
    };
  }

  function readValue(key, fallback) {
    try {
      if (typeof GM_getValue === "function") {
        return GM_getValue(key, fallback);
      }
    } catch (error) {
      // Fall back to localStorage below.
    }

    try {
      const raw = window.localStorage.getItem(key);
      return raw ? JSON.parse(raw) : fallback;
    } catch (error) {
      return fallback;
    }
  }

  function writeValue(key, value) {
    try {
      if (typeof GM_setValue === "function") {
        GM_setValue(key, value);
        return;
      }
    } catch (error) {
      // Fall back to localStorage below.
    }

    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      // Storage can be disabled on some pages.
    }
  }

  function loadSettings() {
    return applySiteProfile(readValue(STORAGE_KEY, DEFAULT_SETTINGS));
  }

  function saveSettings() {
    const storedSettings = normalizeSettings(readValue(STORAGE_KEY, DEFAULT_SETTINGS));
    const nextSettings = {
      ...storedSettings,
      speed: settings.speed,
      volume: settings.volume,
      collapsed: settings.collapsed,
      antiPause: settings.antiPause,
      siteMemory: settings.siteMemory,
      siteProfiles: { ...storedSettings.siteProfiles }
    };

    if (settings.siteMemory) {
      nextSettings.siteProfiles[getSiteKey()] = {
        speed: settings.speed,
        volume: settings.volume,
        antiPause: settings.antiPause
      };
    }

    writeValue(STORAGE_KEY, nextSettings);
    settingsSnapshot = JSON.stringify(applySiteProfile(nextSettings));
  }

  function refreshSettingsFromStorage() {
    const nextSettings = loadSettings();
    const nextSnapshot = JSON.stringify(nextSettings);

    if (nextSnapshot === settingsSnapshot) {
      return false;
    }

    settings = nextSettings;
    settingsSnapshot = nextSnapshot;
    render();
    return true;
  }

  function formatSpeed(speed) {
    return `${speed.toFixed(2).replace(/\.?0+$/, "")}x`;
  }

  function isVideoElement(node) {
    return Boolean(
      node
      && node.nodeType === Node.ELEMENT_NODE
      && String(node.tagName).toLowerCase() === "video"
      && typeof node.play === "function"
    );
  }

  function applySettingsToVideo(video) {
    try {
      video.playbackRate = settings.speed;
      video.defaultPlaybackRate = settings.speed;
    } catch (error) {
      // Some protected players reject playback-rate changes.
    }

    try {
      video.volume = settings.volume / 100;
      video.muted = settings.volume === 0;
    } catch (error) {
      // Keep applying settings to other videos if this one is restricted.
    }
  }

  function getVideoState(video) {
    let state = videoStates.get(video);

    if (!state) {
      state = {
        wasPlaying: false,
        lastPlayingAt: 0,
        resumeTimer: null,
        resumeAttempts: 0
      };
      videoStates.set(video, state);
    }

    return state;
  }

  function isPageInactive() {
    return pageIsBlurred || document.hidden || document.visibilityState === "hidden" || !document.hasFocus();
  }

  function rememberUserActivation() {
    lastUserActivationAt = Date.now();
  }

  function canAutoResume(video) {
    const state = getVideoState(video);
    const wasRecentlyPlaying = state.wasPlaying || Date.now() - state.lastPlayingAt < RESUME_WINDOW_MS;
    const userJustInteracted = Date.now() - lastUserActivationAt < 600;

    return settings.antiPause
      && wasRecentlyPlaying
      && !userJustInteracted
      && isPageInactive()
      && !video.ended
      && video.readyState > 0;
  }

  function tryResume(video) {
    if (!settings.antiPause || !video.paused || video.ended) {
      return;
    }

    applySettingsToVideo(video);

    try {
      const result = video.play();
      if (result && typeof result.catch === "function") {
        result.catch(() => {
          // Browsers can reject play() without a prior user gesture.
        });
      }
    } catch (error) {
      // Some players block programmatic playback.
    }
  }

  function scheduleResume(video) {
    if (!canAutoResume(video)) {
      return;
    }

    const state = getVideoState(video);
    window.clearTimeout(state.resumeTimer);
    state.resumeAttempts = Math.min(state.resumeAttempts + 1, 6);

    state.resumeTimer = window.setTimeout(() => {
      if (canAutoResume(video)) {
        tryResume(video);
      }
    }, state.resumeAttempts * 120);
  }

  function bindVideo(video) {
    if (!isVideoElement(video)) {
      return;
    }

    applySettingsToVideo(video);

    if (controlledVideos.has(video)) {
      return;
    }

    controlledVideos.add(video);
    video.addEventListener("loadedmetadata", () => applySettingsToVideo(video));
    video.addEventListener("playing", () => {
      const state = getVideoState(video);
      state.wasPlaying = true;
      state.lastPlayingAt = Date.now();
      state.resumeAttempts = 0;
      applySettingsToVideo(video);
    });
    video.addEventListener("play", () => {
      const state = getVideoState(video);
      state.wasPlaying = true;
      state.lastPlayingAt = Date.now();
      applySettingsToVideo(video);
    });
    video.addEventListener("pause", () => {
      scheduleResume(video);
    }, true);
    video.addEventListener("ratechange", () => {
      if (video.playbackRate !== settings.speed) {
        window.requestAnimationFrame(() => applySettingsToVideo(video));
      }
    });
    video.addEventListener("volumechange", () => {
      const expectedVolume = settings.volume / 100;
      if (Math.abs(video.volume - expectedVolume) > 0.001 || video.muted !== (settings.volume === 0)) {
        window.requestAnimationFrame(() => applySettingsToVideo(video));
      }
    });
  }

  function scanVideos(root) {
    if (!root) {
      return;
    }

    if (isVideoElement(root)) {
      bindVideo(root);
      return;
    }

    if (typeof root.querySelectorAll === "function") {
      root.querySelectorAll("video").forEach(bindVideo);
      root.querySelectorAll("*").forEach((element) => {
        if (element.shadowRoot) {
          scanVideos(element.shadowRoot);
        }
      });

      root.querySelectorAll("iframe").forEach((iframe) => {
        try {
          if (iframe.contentDocument) {
            scanVideos(iframe.contentDocument);
          }
        } catch (error) {
          // Cross-origin frames are handled by their own userscript instance.
        }
      });
    }
  }

  function applyToPage() {
    scanVideos(document);
  }

  function updateSettings(partial) {
    settings = normalizeSettings({ ...settings, ...partial });
    saveSettings();
    render();
    applyToPage();
  }

  function toggleAntiPause() {
    updateSettings({ antiPause: !settings.antiPause });
  }

  function toggleSiteMemory() {
    updateSettings({ siteMemory: !settings.siteMemory });
  }

  function setCollapsed(collapsed) {
    updateSettings({ collapsed });
  }

  function createUi() {
    if (!isTopFrame()) {
      return;
    }

    if (host) {
      if (!document.documentElement.contains(host)) {
        document.documentElement.appendChild(host);
      }
      return;
    }

    host = document.createElement("div");
    host.id = "video-control-userscript";
    host.style.position = "fixed";
    host.style.right = "max(12px, env(safe-area-inset-right))";
    host.style.bottom = "max(12px, env(safe-area-inset-bottom))";
    host.style.zIndex = "2147483647";

    shadow = host.attachShadow({ mode: "closed" });
    shadow.innerHTML = `
      <style>
        :host {
          color-scheme: light dark;
          font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
          -webkit-tap-highlight-color: transparent;
        }

        * {
          box-sizing: border-box;
        }

        button,
        input {
          font: inherit;
        }

        .panel {
          width: min(300px, calc(100vw - 24px));
          border: 1px solid rgba(15, 23, 42, 0.14);
          border-radius: 8px;
          background: rgba(255, 255, 255, 0.96);
          color: #172033;
          box-shadow: 0 14px 40px rgba(15, 23, 42, 0.2);
          backdrop-filter: blur(10px);
          overflow: hidden;
        }

        .panel.is-collapsed {
          display: none;
        }

        .mini {
          display: none;
          min-width: 52px;
          min-height: 44px;
          border: 1px solid rgba(15, 23, 42, 0.16);
          border-radius: 999px;
          padding: 0 12px;
          background: #0f766e;
          color: #ffffff;
          box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
          font-size: 13px;
          font-weight: 750;
          cursor: pointer;
          touch-action: manipulation;
        }

        .mini.is-visible {
          display: inline-flex;
          align-items: center;
          justify-content: center;
        }

        .header {
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 8px;
          padding: 10px 12px;
          border-bottom: 1px solid rgba(15, 23, 42, 0.1);
        }

        .title {
          margin: 0;
          font-size: 14px;
          font-weight: 750;
        }

        .hide {
          min-width: 36px;
          min-height: 34px;
          border: 1px solid rgba(15, 23, 42, 0.16);
          border-radius: 6px;
          background: #ffffff;
          color: #172033;
          cursor: pointer;
          touch-action: manipulation;
        }

        .body {
          display: grid;
          gap: 12px;
          padding: 12px;
        }

        .control {
          display: grid;
          gap: 8px;
        }

        .switch-row {
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 12px;
          min-height: 34px;
          font-size: 13px;
          font-weight: 650;
        }

        .switch-row input {
          width: 20px;
          height: 20px;
          accent-color: #0f766e;
        }

        .switches {
          display: grid;
          gap: 6px;
        }

        .row {
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 12px;
          font-size: 13px;
          font-weight: 650;
        }

        output {
          color: #0f766e;
          font-variant-numeric: tabular-nums;
          font-weight: 800;
        }

        input[type="range"] {
          width: 100%;
          min-height: 28px;
          accent-color: #0f766e;
        }

        .presets {
          display: grid;
          grid-template-columns: repeat(4, minmax(0, 1fr));
          gap: 6px;
        }

        .presets button {
          min-height: 34px;
          border: 1px solid rgba(15, 23, 42, 0.16);
          border-radius: 6px;
          background: #f8fafc;
          color: #172033;
          font-size: 12px;
          cursor: pointer;
          touch-action: manipulation;
        }

        .presets button:active,
        .hide:active,
        .mini:active {
          transform: translateY(1px);
        }

        @media (max-width: 520px) {
          .panel {
            width: min(280px, calc(100vw - 20px));
          }

          .body {
            gap: 10px;
            padding: 10px;
          }

          .presets {
            grid-template-columns: repeat(3, minmax(0, 1fr));
          }
        }

        @media (prefers-color-scheme: dark) {
          .panel {
            border-color: rgba(226, 232, 240, 0.16);
            background: rgba(23, 27, 34, 0.96);
            color: #edf2f7;
            box-shadow: 0 14px 40px rgba(0, 0, 0, 0.34);
          }

          .header {
            border-bottom-color: rgba(226, 232, 240, 0.12);
          }

          .hide,
          .presets button {
            border-color: rgba(226, 232, 240, 0.16);
            background: #222936;
            color: #edf2f7;
          }

          output {
            color: #5eead4;
          }
        }
      </style>

      <button class="mini" type="button" aria-label="显示视频控制">VC</button>

      <section class="panel" aria-label="视频控制">
        <header class="header">
          <h2 class="title">视频控制</h2>
          <button class="hide" type="button" aria-label="隐藏视频控制">隐藏</button>
        </header>

        <div class="body">
          <div class="control">
            <div class="row">
              <label for="vc-speed">倍速</label>
              <output id="vc-speed-value" for="vc-speed">1x</output>
            </div>
            <input id="vc-speed" type="range" min="0.5" max="5" step="0.05" value="1">
            <div class="presets" aria-label="常用倍速">
              <button type="button" data-speed="0.5">0.5x</button>
              <button type="button" data-speed="1">1x</button>
              <button type="button" data-speed="1.5">1.5x</button>
              <button type="button" data-speed="2">2x</button>
              <button type="button" data-speed="3">3x</button>
              <button type="button" data-speed="5">5x</button>
            </div>
          </div>

          <div class="control">
            <div class="row">
              <label for="vc-volume">音量</label>
              <output id="vc-volume-value" for="vc-volume">100%</output>
            </div>
            <input id="vc-volume" type="range" min="0" max="100" step="1" value="100">
            <div class="presets" aria-label="常用音量">
              <button type="button" data-volume="0">0%</button>
              <button type="button" data-volume="5">5%</button>
              <button type="button" data-volume="8">8%</button>
              <button type="button" data-volume="10">10%</button>
              <button type="button" data-volume="25">25%</button>
              <button type="button" data-volume="50">50%</button>
              <button type="button" data-volume="100">100%</button>
            </div>
          </div>

          <div class="switches">
            <label class="switch-row" for="vc-site-memory">
              <span>本站记忆</span>
              <input id="vc-site-memory" type="checkbox">
            </label>

            <label class="switch-row" for="vc-anti-pause">
              <span>防失焦暂停</span>
              <input id="vc-anti-pause" type="checkbox">
            </label>
          </div>
        </div>
      </section>
    `;

    ui = {
      panel: shadow.querySelector(".panel"),
      mini: shadow.querySelector(".mini"),
      hide: shadow.querySelector(".hide"),
      speed: shadow.getElementById("vc-speed"),
      speedValue: shadow.getElementById("vc-speed-value"),
      volume: shadow.getElementById("vc-volume"),
      volumeValue: shadow.getElementById("vc-volume-value"),
      antiPause: shadow.getElementById("vc-anti-pause"),
      siteMemory: shadow.getElementById("vc-site-memory")
    };

    ui.speed.addEventListener("input", () => updateSettings({ speed: Number(ui.speed.value) }));
    ui.volume.addEventListener("input", () => updateSettings({ volume: Number(ui.volume.value) }));
    ui.antiPause.addEventListener("change", () => updateSettings({ antiPause: ui.antiPause.checked }));
    ui.siteMemory.addEventListener("change", () => updateSettings({ siteMemory: ui.siteMemory.checked }));
    ui.hide.addEventListener("click", () => setCollapsed(true));
    ui.mini.addEventListener("click", () => setCollapsed(false));

    shadow.querySelectorAll("[data-speed]").forEach((button) => {
      button.addEventListener("click", () => updateSettings({ speed: Number(button.dataset.speed) }));
    });

    shadow.querySelectorAll("[data-volume]").forEach((button) => {
      button.addEventListener("click", () => updateSettings({ volume: Number(button.dataset.volume) }));
    });

    document.documentElement.appendChild(host);
    render();
  }

  function render() {
    if (!ui.panel) {
      return;
    }

    ui.speed.value = String(settings.speed);
    ui.speedValue.value = formatSpeed(settings.speed);
    ui.volume.value = String(settings.volume);
    ui.volumeValue.value = `${settings.volume}%`;
    ui.antiPause.checked = settings.antiPause;
    ui.siteMemory.checked = settings.siteMemory;
    ui.panel.classList.toggle("is-collapsed", settings.collapsed);
    ui.mini.classList.toggle("is-visible", settings.collapsed);
  }

  function watchForVideos() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        mutation.addedNodes.forEach(scanVideos);
      }
    });

    observer.observe(document.documentElement || document, {
      childList: true,
      subtree: true
    });
  }

  function installPageGuards() {
    const markBlurred = () => {
      pageIsBlurred = true;
      window.setTimeout(applyToPage, 0);
      window.setTimeout(resumePausedVideos, 160);
    };
    const markFocused = () => {
      pageIsBlurred = false;
      applyToPage();
    };

    ["pointerdown", "mousedown", "touchstart", "keydown"].forEach((eventName) => {
      window.addEventListener(eventName, rememberUserActivation, true);
      document.addEventListener(eventName, rememberUserActivation, true);
    });

    window.addEventListener("blur", markBlurred, true);
    window.addEventListener("focus", markFocused, true);
    document.addEventListener("visibilitychange", () => {
      if (document.hidden || document.visibilityState === "hidden") {
        markBlurred();
      } else {
        markFocused();
      }
    }, true);

    ["mouseleave", "mouseout", "pointerleave"].forEach((eventName) => {
      document.addEventListener(eventName, () => {
        if (!document.hasFocus()) {
          markBlurred();
        }
      }, true);
    });

    patchPauseMethod();
  }

  function patchPauseMethod() {
    const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "pause");
    if (!descriptor || typeof descriptor.value !== "function" || descriptor.value.__videoControlPatched) {
      return;
    }

    const nativePause = descriptor.value;
    const patchedPause = function () {
      if (isVideoElement(this) && canAutoResume(this)) {
        scheduleResume(this);
        return undefined;
      }

      return nativePause.apply(this, arguments);
    };

    patchedPause.__videoControlPatched = true;

    try {
      Object.defineProperty(HTMLMediaElement.prototype, "pause", {
        ...descriptor,
        value: patchedPause
      });
    } catch (error) {
      // Some browsers/pages prevent prototype patching.
    }
  }

  function resumePausedVideos() {
    const videos = [];
    scanVideos(document);
    document.querySelectorAll("video").forEach((video) => videos.push(video));

    videos.forEach((video) => {
      if (canAutoResume(video)) {
        tryResume(video);
      }
    });
  }

  function installHistoryGuards() {
    const scheduleAfterNavigation = () => {
      window.setTimeout(bootstrap, 80);
      window.setTimeout(bootstrap, 500);
      window.setTimeout(bootstrap, 1500);
    };

    ["pushState", "replaceState"].forEach((methodName) => {
      const original = history[methodName];
      if (typeof original !== "function" || original.__videoControlPatched) {
        return;
      }

      history[methodName] = function () {
        const result = original.apply(this, arguments);
        scheduleAfterNavigation();
        return result;
      };
      history[methodName].__videoControlPatched = true;
    });

    window.addEventListener("popstate", scheduleAfterNavigation, true);
    window.addEventListener("hashchange", scheduleAfterNavigation, true);
  }

  function bootstrap() {
    if (!document.documentElement) {
      return;
    }

    const changed = refreshSettingsFromStorage();
    createUi();
    applyToPage();

    if (changed) {
      window.setTimeout(applyToPage, 80);
    }

    if (lastUrl !== window.location.href) {
      lastUrl = window.location.href;
      window.setTimeout(applyToPage, 300);
      window.setTimeout(applyToPage, 1200);
    }
  }

  function startBootstrapLoop() {
    window.clearInterval(bootstrapTimer);
    bootstrapTimer = window.setInterval(bootstrap, BOOTSTRAP_INTERVAL_MS);
    bootstrap();
  }

  function registerMenuCommands() {
    if (typeof GM_registerMenuCommand !== "function") {
      return;
    }

    GM_registerMenuCommand("显示/隐藏视频控制", () => {
      setCollapsed(!settings.collapsed);
    });

    GM_registerMenuCommand("开启/关闭防失焦暂停", () => {
      toggleAntiPause();
    });

    GM_registerMenuCommand("开启/关闭本站记忆", () => {
      toggleSiteMemory();
    });

    GM_registerMenuCommand("恢复默认视频设置", () => {
      updateSettings(DEFAULT_SETTINGS);
    });
  }

  installPageGuards();
  installHistoryGuards();
  startBootstrapLoop();
  watchForVideos();
  if (isTopFrame()) {
    registerMenuCommands();
  }
})();