视频全页面适配·视频倍速播放器·纯净播放

将视频播放窗口适配为全页面显示

// ==UserScript==
// @name         视频全页面适配·视频倍速播放器·纯净播放
// @namespace    https://toolsdar.cn/
// @version      0.4
// @description  将视频播放窗口适配为全页面显示
// @author       Your name
// @match        *://*/*
// @grant        none
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  /** ---------------- 存储封装(Tampermonkey 优先,回退 localStorage) ---------------- */
  const Storage = (() => {
    const hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
    const GET = (k, d) => {
      try {
        if (hasGM) return GM_getValue(k, d);
        const v = localStorage.getItem(k);
        return v == null ? d : JSON.parse(v);
      } catch { return d; }
    };
    const SET = (k, v) => {
      try {
        if (hasGM) return GM_setValue(k, v);
        localStorage.setItem(k, JSON.stringify(v));
      } catch {}
    };
    return { get: GET, set: SET };
  })();

  const KEY_ENABLED = 'videoFullpage:enabledSites';
  const KEY_POS     = 'videoFullpage:btnPos'; // 记录按钮位置
  const host = location.hostname;

  function loadEnabledMap() {
    const map = Storage.get(KEY_ENABLED, null);
    return map && typeof map === 'object' ? map : {};
  }
  function saveEnabledMap(map) { Storage.set(KEY_ENABLED, map); }
  function isSiteEnabled(h = host) {
    const map = loadEnabledMap();
    return !!map[h];
  }
  function setSiteEnabled(enabled, h = host) {
    const map = loadEnabledMap();
    if (enabled) map[h] = true;
    else delete map[h];
    saveEnabledMap(map);
  }

  function loadPosMap() {
    const map = Storage.get(KEY_POS, null);
    return map && typeof map === 'object' ? map : {};
  }
  function savePosMap(m) { Storage.set(KEY_POS, m); }
  function getBtnPos(name, h = host) {
    const m = loadPosMap();
    return (m[h] && m[h][name]) ? m[h][name] : null;
  }
  function setBtnPos(name, pos, h = host) {
    const m = loadPosMap();
    if (!m[h]) m[h] = {};
    m[h][name] = pos; // {left, top}
    savePosMap(m);
  }

  /** ---------------- 工具:等待 body、拖动封装 ---------------- */
  function ensureBody(cb) {
    if (document.body) return cb();
    const obs = new MutationObserver(() => {
      if (document.body) {
        obs.disconnect();
        cb();
      }
    });
    obs.observe(document.documentElement, { childList: true, subtree: true });
  }

  /**
   * 让任意元素可拖动并记住位置
   * @param {HTMLElement} el  按钮元素
   * @param {string} name     名称:'siteToggle' 或 'actionButton'
   * @param {object} [opt]    { minX, minY, maxX, maxY } 可选
   */
  function makeDraggable(el, name, opt = {}) {
    el.style.position = 'fixed';
    el.style.cursor = 'move';

    // 载入持久化位置
    const saved = getBtnPos(name);
    if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') {
      el.style.left = saved.left + 'px';
      el.style.top  = saved.top  + 'px';
    } else {
      // 默认位置:左上/右上
      if (name === 'siteToggle') { el.style.left = '20px'; el.style.top = '20px'; }
      else                       { el.style.right = '20px'; el.style.top = '20px'; } // actionButton 默认在右上
    }

    // 将 right 统一换算为 left,避免混用
    if (el.style.right) {
      const rightPx = parseFloat(getComputedStyle(el).right) || 0;
      const left = window.innerWidth - el.offsetWidth - rightPx;
      el.style.right = '';
      el.style.left = Math.max(0, left) + 'px';
    }

    let dragging = false;
    let startX = 0, startY = 0;
    let origLeft = 0, origTop = 0;
    let moved = false; // 抑制点击误触

    const threshold = 3;

    const getPoint = (ev) => {
      if (ev.touches && ev.touches[0]) return { x: ev.touches[0].clientX, y: ev.touches[0].clientY };
      return { x: ev.clientX, y: ev.clientY };
    };

    const clamp = (v, min, max) => Math.min(Math.max(v, min), max);

    const onDown = (e) => {
      const p = getPoint(e);
      dragging = true;
      moved = false;
      startX = p.x; startY = p.y;
      origLeft = parseFloat(getComputedStyle(el).left) || 0;
      origTop  = parseFloat(getComputedStyle(el).top)  || 0;

      document.addEventListener('mousemove', onMove, { passive: false });
      document.addEventListener('mouseup', onUp, { passive: false });
      document.addEventListener('touchmove', onMove, { passive: false });
      document.addEventListener('touchend', onUp, { passive: false });
      e.preventDefault();
      e.stopPropagation();
    };

    const onMove = (e) => {
      if (!dragging) return;
      const p = getPoint(e);
      const dx = p.x - startX;
      const dy = p.y - startY;
      if (!moved && Math.hypot(dx, dy) > threshold) moved = true;

      let newLeft = origLeft + dx;
      let newTop  = origTop  + dy;

      const minX = (typeof opt.minX === 'number') ? opt.minX : 0;
      const minY = (typeof opt.minY === 'number') ? opt.minY : 0;
      const maxX = (typeof opt.maxX === 'number') ? opt.maxX : (window.innerWidth  - el.offsetWidth);
      const maxY = (typeof opt.maxY === 'number') ? opt.maxY : (window.innerHeight - el.offsetHeight);

      newLeft = clamp(newLeft, minX, Math.max(minX, maxX));
      newTop  = clamp(newTop,  minY, Math.max(minY, maxY));

      el.style.left = newLeft + 'px';
      el.style.top  = newTop  + 'px';

      e.preventDefault();
      e.stopPropagation();
    };

    const onUp = (e) => {
      if (!dragging) return;
      dragging = false;

      // 保存位置
      const left = parseFloat(getComputedStyle(el).left) || 0;
      const top  = parseFloat(getComputedStyle(el).top)  || 0;
      setBtnPos(name, { left, top });

      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      document.removeEventListener('touchmove', onMove);
      document.removeEventListener('touchend', onUp);

      // 如果拖动过,就阻止点击触发
      if (moved) {
        e.preventDefault();
        e.stopPropagation();
      }
    };

    el.addEventListener('mousedown', onDown);
    el.addEventListener('touchstart', onDown, { passive: false });

    // 点击抑制:若刚拖完,阻止一次 click
    el.addEventListener('click', (e) => {
      if (moved) {
        e.preventDefault();
        e.stopPropagation();
        moved = false;
      }
    }, true);

    // 窗口尺寸变化时,确保按钮仍在可视范围内
    window.addEventListener('resize', () => {
      const left = parseFloat(getComputedStyle(el).left) || 0;
      const top  = parseFloat(getComputedStyle(el).top)  || 0;
      const clampedLeft = Math.min(left, window.innerWidth - el.offsetWidth);
      const clampedTop  = Math.min(top,  window.innerHeight - el.offsetHeight);
      el.style.left = Math.max(0, clampedLeft) + 'px';
      el.style.top  = Math.max(0, clampedTop)  + 'px';
      setBtnPos(name, { left: Math.max(0, clampedLeft), top: Math.max(0, clampedTop) });
    });
  }

  /** ---------------- UI:站点启用/禁用开关 ---------------- */
  let siteToggleBtn = null;
  let videoFullpageInstance = null;

  function renderSiteToggle() {
    if (siteToggleBtn) return;
    siteToggleBtn = document.createElement('button');
    siteToggleBtn.className = 'vf-site-toggle';
    siteToggleBtn.style.cssText = `
      position: fixed;
      left: 20px;
      top: 20px;
      z-index: 2147483646;
      background: rgba(0,0,0,.6);
      color: #fff;
      border: none;
      border-radius: 16px;
      padding: 6px 10px;
      font-size: 12px;
      cursor: move;
      user-select: none;
      backdrop-filter: saturate(120%) blur(2px);
    `;
    updateSiteToggleText();

    siteToggleBtn.addEventListener('click', () => {
      const enabled = !isSiteEnabled();
      setSiteEnabled(enabled);
      updateSiteToggleText();

      // 如果在全页面模式且被关闭,则立即退出
      if (!enabled && videoFullpageInstance) {
        const v = document.querySelector('video.video-fullpage');
        if (v) videoFullpageInstance.restoreOriginalState(v);
      }
    });

    document.body.appendChild(siteToggleBtn);
    // 让站点按钮可拖动并记住位置
    makeDraggable(siteToggleBtn, 'siteToggle');
  }

  function updateSiteToggleText() {
    const enabled = isSiteEnabled();
    if (siteToggleBtn) {
      siteToggleBtn.textContent = enabled ? '本网站:已启用' : '本网站:未启用';
      siteToggleBtn.style.background = enabled ? 'rgba(34,197,94,.85)' : 'rgba(0,0,0,.6)';
    }
  }

  // 菜单命令(可选):控制当前站点开关
  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('切换本网站开关', () => {
      const enabled = !isSiteEnabled();
      setSiteEnabled(enabled);
      updateSiteToggleText();
      alert(`已${enabled ? '启用' : '禁用'}:${host}`);
    });
    GM_registerMenuCommand('清空所有站点开关', () => {
      Storage.set(KEY_ENABLED, {});
      alert('已清空所有站点的启用状态');
      updateSiteToggleText();
    });
    GM_registerMenuCommand('清空按钮位置', () => {
      const map = loadPosMap();
      if (map[host]) delete map[host];
      savePosMap(map);
      alert('已清空本域名下的按钮位置,刷新后恢复默认');
    });
  }

  /** ---------------- 原有功能(仅在启用站点时可用) ---------------- */
  class VideoFullpage {
    constructor() {
      this.handleGlobalKey = this.handleGlobalKey.bind(this);
      document.addEventListener('keydown', this.handleGlobalKey, { capture: true });
      this.originalStates = new Map();
    }

    // 仅站点启用时响应热键
    handleGlobalKey(e) {
      if (!isSiteEnabled()) return;
      if (e.code === 'KeyH' && !e.ctrlKey && !e.altKey && !e.metaKey) {
        e.preventDefault();
        const video = document.querySelector('video');
        if (!video) return;

        if (video.classList.contains('video-fullpage')) {
          this.toggleFullpage(video);
        } else {
          if (!this.initialized) {
            this.createButton();
            this.initialized = true;
          }
          this.toggleFullpage(video);
        }
      }
    }

    createButton() {
      const button = document.createElement('button');
      this.button = button;
      button.innerHTML = '全页面';
      button.style.cssText = `
        position: fixed;
        right: 20px;
        top: 20px;
        z-index: 2147483646;
        background: rgba(0, 0, 0, 0.6);
        color: white;
        border: none;
        border-radius: 4px;
        padding: 8px 16px;
        cursor: move;
        font-size: 14px;
        transition: background-color 0.3s;
        display: none;
      `;

      const style = document.createElement('style');
      style.textContent = `.active { background: rgba(0, 0, 0, 0.9) !important; }`;
      document.head.appendChild(style);

      document.body.appendChild(button);

      // 点击(非拖动)触发
      button.addEventListener('click', () => {
        if (!isSiteEnabled()) return;
        const video = document.querySelector('video');
        if (video) {
          this.toggleFullpage(video);
          button.classList.toggle('active');
        }
      });

      // 可拖动并记住位置(名称为 actionButton)
      makeDraggable(button, 'actionButton');
    }

    toggleFullpage(video) {
      if (video.classList.contains('video-fullpage')) {
        this.restoreOriginalState(video);
        if (this.button) {
          this.button.classList.remove('active');
          this.button.style.display = 'none';
        }
      } else {
        this.saveOriginalState(video);
        this.enterFullpage(video);
        if (this.button) {
          this.button.classList.add('active');
          this.button.style.display = 'block';
        }
      }
    }

    saveOriginalState(video) {
      const originalState = {
        style: video.style.cssText,
        parentNode: video.parentNode,
        nextSibling: video.nextSibling,
        scrollTop: window.scrollY,
        scrollLeft: window.scrollX,
        bodyOverflow: document.body.style.overflow,
        bodyPosition: document.body.style.position,
        videoPosition: {
          width: video.offsetWidth,
          height: video.offsetHeight,
          rect: video.getBoundingClientRect()
        }
      };
      this.originalStates.set(video, originalState);
    }

    restoreOriginalState(video) {
      const state = this.originalStates.get(video);
      if (!state) return;

      if (this.videoEvents) {
        video.removeEventListener('click', this.videoEvents.click);
        document.removeEventListener('keydown', this.videoEvents.keydown);
        this.videoEvents = null;
      }

      const container = document.querySelector('.video-fullpage-container');
      if (container) {
        if (state.parentNode) {
          if (state.nextSibling) state.parentNode.insertBefore(video, state.nextSibling);
          else state.parentNode.appendChild(video);
        }
        container.remove();
      }

      video.classList.remove('video-fullpage');
      video.style.cssText = state.style || '';

      document.body.style.overflow = state.bodyOverflow || '';
      document.body.style.position = state.bodyPosition || '';

      requestAnimationFrame(() => {
        window.scrollTo({ left: state.scrollLeft || 0, top: state.scrollTop || 0, behavior: 'instant' });
      });

      window.removeEventListener('resize', this.resizeHandler);

      this.originalStates.delete(video);

      const hint = document.querySelector('.video-seek-hint');
      if (hint) hint.remove();

      video.style.cssText = state.style || '';
      video.style.width = '';
      video.style.height = '';
      video.style.maxWidth = '100%';
      video.style.maxHeight = '100%';

      video.dispatchEvent(new CustomEvent('exitfullpage'));
      // 触发布局回流
      // eslint-disable-next-line no-unused-expressions
      video.offsetHeight;

      if (this.progressCleanup) {
        this.progressCleanup();
        this.progressCleanup = null;
      }
    }

    enterFullpage(video) {
      const container = document.createElement('div');
      container.className = 'video-fullpage-container';
      container.style.cssText = `
        position: fixed;
        top: 0; left: 0;
        width: 100vw; height: 100vh;
        background: rgba(0, 0, 0, 0.9);
        z-index: 2147483646;
        display: flex; justify-content: center; align-items: center;
      `;

      video.classList.add('video-fullpage');

      const updateVideoSize = () => {
        const windowWidth = window.innerWidth;
        const windowHeight = window.innerHeight;
        const videoRatio = video.videoWidth / video.videoHeight;
        const windowRatio = windowWidth / windowHeight;

        if (windowRatio > videoRatio) {
          video.style.cssText = `
            height: ${windowHeight}px !important;
            width: ${windowHeight * videoRatio}px !important;
            position: relative !important;
            z-index: 2147483647 !important;
            background: transparent !important;
            margin: 0 !important; padding: 0 !important;
            cursor: pointer !important;
          `;
        } else {
          video.style.cssText = `
            width: ${windowWidth}px !important;
            height: ${windowWidth / videoRatio}px !important;
            position: relative !important;
            z-index: 2147483647 !important;
            background: transparent !important;
            margin: 0 !important; padding: 0 !important;
            cursor: pointer !important;
          `;
        }
      };

      this.resizeHandler = updateVideoSize;
      window.addEventListener('resize', this.resizeHandler);

      const handleClick = (e) => {
        e.preventDefault();
        e.stopPropagation();
        if (video.paused) video.play();
        else video.pause();
      };

      const handleKeydown = (e) => {
        if (!video.classList.contains('video-fullpage')) return;

        switch (e.code) {
          case 'Space':
            e.preventDefault();
            if (video.paused) video.play();
            else video.pause();
            break;
          case 'ArrowLeft':
          case 'KeyA':
            e.preventDefault();
            video.currentTime = Math.max(0, video.currentTime - 3);
            this.showSeekHint(video, '⏪ -3s');
            break;
          case 'ArrowRight':
          case 'KeyD':
            e.preventDefault();
            video.currentTime = Math.min(video.duration, video.currentTime + 3);
            this.showSeekHint(video, '⏩ +3s');
            break;
          case 'ArrowUp':
          case 'KeyW':
            e.preventDefault();
            video.volume = Math.min(1, video.volume + 0.1);
            this.showSeekHint(video, `🔊 ${Math.round(video.volume * 100)}%`);
            break;
          case 'ArrowDown':
          case 'KeyS':
            e.preventDefault();
            video.volume = Math.max(0, video.volume - 0.1);
            this.showSeekHint(video, `🔉 ${Math.round(video.volume * 100)}%`);
            break;
          case 'KeyC':
            e.preventDefault();
            video.playbackRate = Math.min(16, video.playbackRate + 0.1);
            this.showSeekHint(video, `⏩ ${video.playbackRate.toFixed(1)}x`);
            break;
          case 'KeyX':
            e.preventDefault();
            video.playbackRate = Math.max(0.1, video.playbackRate - 0.1);
            this.showSeekHint(video, `⏪ ${video.playbackRate.toFixed(1)}x`);
            break;
          case 'KeyZ':
            e.preventDefault();
            video.playbackRate = 1.0;
            this.showSeekHint(video, '⏮ 1.0x');
            break;
        }
      };

      video.addEventListener('click', handleClick);
      document.addEventListener('keydown', handleKeydown);

      this.videoEvents = { click: handleClick, keydown: handleKeydown };

      container.appendChild(video);
      document.body.appendChild(container);
      document.body.style.overflow = 'hidden';

      if (video.readyState >= 1) updateVideoSize();
      else video.addEventListener('loadedmetadata', updateVideoSize, { once: true });

      // 进度条
      const progressContainer = document.createElement('div');
      progressContainer.className = 'video-progress-container';
      progressContainer.style.cssText = `
        position: absolute; bottom: 0; left: 0; right: 0;
        height: 40px;
        background: linear-gradient(transparent, rgba(0,0,0,.7));
        opacity: 0; transition: opacity .3s;
        display: flex; align-items: center;
        padding: 0 20px; z-index: 2147483647;
      `;

      const progress = document.createElement('div');
      progress.className = 'video-progress';
      progress.style.cssText = `
        position: relative; width: 100%; height: 4px;
        background: rgba(255,255,255,.3);
        border-radius: 2px; cursor: pointer;
      `;

      const progressFill = document.createElement('div');
      progressFill.className = 'video-progress-fill';
      progressFill.style.cssText = `
        position: absolute; left: 0; top: 0; height: 100%;
        background: #ff0000; border-radius: 2px;
      `;

      const timeDisplay = document.createElement('div');
      timeDisplay.className = 'video-time-display';
      timeDisplay.style.cssText = `
        color: #fff; margin-left: 10px; font-size: 14px; min-width: 100px; text-align: right;
      `;

      progress.appendChild(progressFill);
      progressContainer.appendChild(progress);
      progressContainer.appendChild(timeDisplay);

      const formatTime = (seconds) => {
        const h = Math.floor(seconds / 3600);
        const m = Math.floor((seconds % 3600) / 60);
        const s = Math.floor(seconds % 60);
        if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
        return `${m}:${s.toString().padStart(2, '0')}`;
      };

      const updateProgress = () => {
        const percent = (video.currentTime / video.duration) * 100;
        progressFill.style.width = `${percent}%`;
        timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
      };

      let isDragging = false;
      const handleProgressClick = (e) => {
        const rect = progress.getBoundingClientRect();
        const percent = (e.clientX - rect.left) / rect.width;
        video.currentTime = video.duration * Math.min(1, Math.max(0, percent));
        updateProgress();
      };

      progress.addEventListener('mousedown', (e) => {
        isDragging = true;
        handleProgressClick(e);
      });
      document.addEventListener('mousemove', (e) => {
        if (isDragging) handleProgressClick(e);
      });
      document.addEventListener('mouseup', () => { isDragging = false; });

      let hideTimeout;
      container.addEventListener('mousemove', () => {
        progressContainer.style.opacity = '1';
        clearTimeout(hideTimeout);
        hideTimeout = setTimeout(() => {
          if (!isDragging) progressContainer.style.opacity = '0';
        }, 2000);
      });
      container.addEventListener('mouseleave', () => {
        if (!isDragging) progressContainer.style.opacity = '0';
      });

      video.addEventListener('timeupdate', updateProgress);
      container.appendChild(progressContainer);

      const cleanup = () => {
        video.removeEventListener('timeupdate', updateProgress);
        clearTimeout(hideTimeout);
      };
      this.progressCleanup = cleanup;
    }

    showSeekHint(video, text) {
      const existingHint = document.querySelector('.video-seek-hint');
      if (existingHint) existingHint.remove();

      const hint = document.createElement('div');
      hint.className = 'video-seek-hint';
      hint.textContent = text;
      hint.style.cssText = `
        position: absolute; top: 50%; left: 50%;
        transform: translate(-50%, -50%);
        background: rgba(0,0,0,.8); color: #fff;
        padding: 10px 20px; border-radius: 4px; font-size: 16px;
        pointer-events: none; z-index: 2147483648;
        animation: vf-fadeOut .5s ease-out .5s forwards;
      `;

      const style = document.createElement('style');
      style.textContent = `@keyframes vf-fadeOut { from { opacity: 1; } to { opacity: 0; } }`;
      document.head.appendChild(style);

      const container = document.querySelector('.video-fullpage-container');
      if (container) {
        container.appendChild(hint);
        setTimeout(() => hint.remove(), 1000);
      }
    }
  }

  // 初始化 UI 与实例
  ensureBody(() => {
    renderSiteToggle();
    videoFullpageInstance = new VideoFullpage();
  });
})();