Video Control

Adjust HTML5 video playback speed and volume on webpages.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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();
  }
})();