Video Control

Adjust HTML5 video playback speed and volume on webpages.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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