Greasy Fork

Greasy Fork is available in English.

视频倍速播放快捷键

为网页视频添加统一的倍速播放快捷键:→ 方向键点按快进、长按倍速,← 方向键后退;长按 3 倍速,双击长按 6 倍速。适配了哔哩哔哩、抖音、小红书、知乎、微博、X、Facebook、Instagram、YouTube、腾讯视频、爱奇艺、优酷、PPTV、芒果TV、乐视视频、搜狐视频、咪咕视频、今日头条、极客时间

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         视频倍速播放快捷键
// @version      2.1.0
// @description  为网页视频添加统一的倍速播放快捷键:→ 方向键点按快进、长按倍速,← 方向键后退;长按 3 倍速,双击长按 6 倍速。适配了哔哩哔哩、抖音、小红书、知乎、微博、X、Facebook、Instagram、YouTube、腾讯视频、爱奇艺、优酷、PPTV、芒果TV、乐视视频、搜狐视频、咪咕视频、今日头条、极客时间
// @author       sakura-flutter
// @namespace    https://github.com/sakura-flutter/tampermonkey-scripts
// @license      GPL-3.0
// @run-at       document-start
// @match        *://www.bilibili.com/*
// @match        *://www.douyin.com/*
// @match        *://www.xiaohongshu.com/*
// @match        *://www.zhihu.com/*
// @match        *://*.weibo.com/*
// @match        *://x.com/*
// @match        *://www.facebook.com/*
// @match        *://www.instagram.com/*
// @match        *://www.youtube.com/*
// @match        *://v.qq.com/*
// @match        *://www.iqiyi.com/*
// @match        *://www.youku.com/*
// @match        *://v.youku.com/*
// @match        *://www.pptv.com/*
// @match        *://v.pptv.com/*
// @match        *://www.mgtv.com/*
// @match        *://tv.le.com/*
// @match        *://www.le.com/*
// @match        *://tv.sohu.com/*
// @match        *://www.miguvideo.com/*
// @match        *://www.toutiao.com/*
// @match        *://time.geekbang.org/*
// ==/UserScript==

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";

;// ./src/scripts/playback-rate/multi-press.ts
// =====================================================
// Name: 多击键盘事件
// Author: AI
// =====================================================

/**
 * 多击长按事件配置
 */

/**
 * 多击长按事件详情
 */

/**
 * 事件回调函数类型
 */

/**
 * 多击长按键盘事件处理器
 *
 * 功能特点:
 * - 支持单次、两次、三次、四次等任意多击检测
 * - 支持长按检测(按下后持续一段时间)
 * - 支持双击长按、三击长按等组合模式
 * - 可配置时间阈值
 * - 支持长按期间重复触发
 *
 * @example
 * ```typescript
 * // 创建处理器
 * const handler = new MultiPress({
 *   pressInterval: 300,      // 多击间隔 300ms
 *   longPressThreshold: 500, // 长按阈值 500ms
 *   enableRepeat: true,      // 启用重复触发
 *   repeatInterval: 100      // 每 100ms 重复一次
 * });
 *
 * // 监听单击长按(按一次并长按)
 * handler.on('Space', 1, (event) => {
 *   console.log('空格单击长按', event.pressDuration);
 * });
 *
 * // 监听双击长按(按两次后第二次长按)
 * handler.on('Enter', 2, (event) => {
 *   console.log('回车双击长按', event.pressCount);
 * });
 *
 * // 监听三击长按
 * handler.on('ArrowUp', 3, (event) => {
 *   console.log('上箭头三击长按');
 * });
 *
 * // 启动监听
 * handler.start();
 *
 * // 停止监听
 * handler.stop();
 * ```
 */
class MultiPress {
  constructor(config) {
    const defaultConfig = {
      pressInterval: 150,
      longPressThreshold: 350,
      enableRepeat: false,
      repeatInterval: 100,
      onKeydown() {},
      onKeyup() {}
    };
    this.config = {
      ...defaultConfig,
      ...config
    };
    this.listeners = new Map();
    this.keyStates = new Map();
    this.isActive = false;
    this.boundKeyDown = this.handleKeyDown.bind(this);
    this.boundKeyUp = this.handleKeyUp.bind(this);
  }

  /**
   * 注册事件监听器
   * @param key 按键代码(如 'Space', 'Enter', 'a', 'A', 'ArrowUp' 等)
   * @param pressCount 按键次数(1=单次, 2=两次, 3=三次, ...)
   * @param callback 回调函数
   */
  on(key, pressCount, callback) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, new Map());
    }
    const keyListeners = this.listeners.get(key);
    if (!keyListeners.has(pressCount)) {
      keyListeners.set(pressCount, []);
    }
    keyListeners.get(pressCount).push(callback);
  }

  /**
   * 移除事件监听器
   * @param key 按键代码
   * @param pressCount 按键次数
   * @param callback 要移除的回调函数(可选,不传则移除该按键和按键次数的所有监听器)
   */
  off(key, pressCount, callback) {
    if (!this.listeners.has(key)) return;
    const keyListeners = this.listeners.get(key);
    if (pressCount === undefined) {
      // 移除该按键的所有监听器
      this.listeners.delete(key);
      return;
    }
    if (!keyListeners.has(pressCount)) return;
    if (callback === undefined) {
      // 移除该按键和按键次数的所有监听器
      keyListeners.delete(pressCount);
    } else {
      // 移除特定回调
      const callbacks = keyListeners.get(pressCount);
      const index = callbacks.indexOf(callback);
      if (index !== -1) {
        callbacks.splice(index, 1);
      }
      if (callbacks.length === 0) {
        keyListeners.delete(pressCount);
      }
    }
    if (keyListeners.size === 0) {
      this.listeners.delete(key);
    }
  }

  /**
   * 获取当前配置
   */
  getConfig() {
    return {
      ...this.config
    };
  }

  /**
   * 更新配置
   */
  updateConfig(config) {
    this.config = {
      ...this.config,
      ...config
    };
  }

  /**
   * 启动键盘事件监听
   */
  start() {
    if (this.isActive) return;
    this.isActive = true;
    window.addEventListener('keydown', this.boundKeyDown, true);
    window.addEventListener('keyup', this.boundKeyUp, true);
  }

  /**
   * 停止键盘事件监听
   */
  stop() {
    if (!this.isActive) return;
    this.isActive = false;
    window.removeEventListener('keydown', this.boundKeyDown, true);
    window.removeEventListener('keyup', this.boundKeyUp, true);
    this.clearAllTimers();
  }

  /**
   * 销毁处理器,清理所有资源
   */
  destroy() {
    this.stop();
    this.listeners.clear();
    this.keyStates.clear();
  }
  handleKeyDown(event) {
    this.config.onKeydown(event);
    const key = event.key;

    // 避免重复触发(按住不放)
    if (event.repeat) return;
    const now = Date.now();
    let state = this.keyStates.get(key);
    if (!state) {
      state = {
        pressCount: 0,
        lastKeyDownTime: 0,
        lastKeyUpTime: 0,
        longPressTimer: null,
        repeatTimer: null,
        pressResetTimer: null,
        pressTriggerTimer: null,
        isLongPressing: false,
        pressStartTime: 0,
        lastEvent: null
      };
      this.keyStates.set(key, state);
    }

    // 判断是否为连续点击
    const timeSinceLastUp = now - state.lastKeyUpTime;
    if (timeSinceLastUp <= this.config.pressInterval && state.lastKeyUpTime > 0) {
      // 连续点击,增加计数
      state.pressCount++;
    } else {
      // 新的点击序列
      state.pressCount = 1;
    }
    state.lastKeyDownTime = now;
    state.pressStartTime = now;
    state.isLongPressing = false;
    state.lastEvent = event;

    // 清除之前的定时器(包括按键触发定时器)
    this.clearTimers(state);
    if (state.pressTriggerTimer !== null) {
      clearTimeout(state.pressTriggerTimer);
      state.pressTriggerTimer = null;
    }

    // 设置长按检测定时器
    state.longPressTimer = window.setTimeout(() => {
      state.isLongPressing = true;
      this.triggerEvent(key, state.pressCount, true, event, now, false);

      // 如果启用重复触发
      if (this.config.enableRepeat) {
        state.repeatTimer = window.setInterval(() => {
          if (state.isLongPressing) {
            // TODO: 这里 `event.repeat` 应该为 true
            this.triggerEvent(key, state.pressCount, true, event, state.pressStartTime, true);
          }
        }, this.config.repeatInterval);
      }
    }, this.config.longPressThreshold);
  }
  handleKeyUp(event) {
    this.config.onKeyup(event);
    const key = event.key;
    const state = this.keyStates.get(key);
    if (!state) return;
    const now = Date.now();
    state.lastKeyUpTime = now;

    // 清除长按和重复定时器
    this.clearTimers(state);

    // 如果没有触发长按,需要延迟触发以等待可能的后续点击
    if (!state.isLongPressing) {
      // 清除之前的延迟触发定时器
      if (state.pressTriggerTimer !== null) {
        clearTimeout(state.pressTriggerTimer);
        state.pressTriggerTimer = null;
      }

      // 保存当前的 pressCount 和 event
      const currentPressCount = state.pressCount;
      const currentEvent = state.lastEvent || event;

      // 设置延迟触发定时器,等待 pressInterval 时间
      // 如果在这个时间内没有新的点击,则触发当前次数的回调
      state.pressTriggerTimer = window.setTimeout(() => {
        // 触发当前点击次数的事件
        this.triggerEvent(key, currentPressCount, false, currentEvent);

        // 重置按键计数
        state.pressCount = 0;
        state.pressTriggerTimer = null;
      }, this.config.pressInterval);
    } else {
      // 如果已经触发长按,只需要重置状态
      state.isLongPressing = false;

      // 设置按键计数重置定时器
      state.pressResetTimer = window.setTimeout(() => {
        state.pressCount = 0;
      }, this.config.pressInterval);
    }
  }
  triggerEvent(key, pressCount, isLongPress, originalEvent, pressStartTime, isRepeat = false) {
    const keyListeners = this.listeners.get(key);
    if (!keyListeners) return;
    const callbacks = keyListeners.get(pressCount);
    if (!callbacks || callbacks.length === 0) return;
    const eventDetail = {
      key,
      pressCount,
      isLongPress,
      isRepeat,
      pressDuration: pressStartTime ? Date.now() - pressStartTime : undefined,
      originalEvent
    };
    callbacks.forEach(callback => {
      try {
        callback(eventDetail);
      } catch (error) {
        console.error('MultiPress callback error:', error);
      }
    });
  }
  clearTimers(state) {
    if (state.longPressTimer !== null) {
      clearTimeout(state.longPressTimer);
      state.longPressTimer = null;
    }
    if (state.repeatTimer !== null) {
      clearInterval(state.repeatTimer);
      state.repeatTimer = null;
    }
  }
  clearAllTimers() {
    this.keyStates.forEach(state => {
      this.clearTimers(state);
      if (state.pressResetTimer !== null) {
        clearTimeout(state.pressResetTimer);
        state.pressResetTimer = null;
      }
      if (state.pressTriggerTimer !== null) {
        clearTimeout(state.pressTriggerTimer);
        state.pressTriggerTimer = null;
      }
    });
  }
}

/**
 * 便捷工厂函数
 */
function createMultiPress(config) {
  return new MultiPress(config);
}
;// ./src/utils/selector.ts
const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);
;// ./src/utils/log.ts
const isDebug = "production" !== 'production';
function warn(...args) {
  isDebug && warn.force(...args);
}
warn.force = function (...args) {
  console.warn('%c      warn      ', 'background: #ffa500; padding: 1px; color: #fff;', ...args);
};
function error(...args) {
  isDebug && error.force(...args);
}
error.force = function (...args) {
  console.error('%c      error      ', 'background: red; padding: 1px; color: #fff;', ...args);
};
function table(...args) {
  isDebug && console.table(...args);
}

;// ./src/scripts/playback-rate/utils.ts



/** 判断视频是否正在播放 */
function isPlaying(video) {
  return !video.paused && !video.ended;
}

/** 判断视频是否有声音(非静音) */
function isAudible(video) {
  return !video.muted && video.volume > 0;
}

/** 计算矩形中心到视口中心的距离 */
function getDistanceFromViewportCenter(rect) {
  const viewportCenterX = window.innerWidth / 2;
  const viewportCenterY = window.innerHeight / 2;
  const videoCenterX = rect.left + rect.width / 2;
  const videoCenterY = rect.top + rect.height / 2;
  const dx = videoCenterX - viewportCenterX;
  const dy = videoCenterY - viewportCenterY;
  // 这里不需要 `Math.sqrt` 开根号,避免计算开销,比较时平方距离也是有效的
  return dx * dx + dy * dy;
}

/**
 * 查找页面中最符合条件的视频元素
 *
 * 多个视频元素时的权重优先级:
 * 1. 播放状态 (播放中 > 其他):只有播放中才需要倍速
 * 2. 音频状态 (有声 > 静音):如果有多个视频同时播放,优先处理有声音的,因为静音的通常是广告或背景视频,
 * 理想情况下不会出现多个有声音的视频同时播放
 * 3. 元素大小 (大 > 小):大尺寸的视频通常是主要内容,虽然背景视频尺寸可能更大但通常都是静音的
 * 4. 视口距离 (距离视口中心近 > 远):短视频或信息流页面可滚动时,优先处理视口中心附近的视频
 */
function findBestVideoElement() {
  // 优先级 1 播放状态:播放中优先
  const videos = Array.from($$('video')).filter(video => isPlaying(video));
  if (videos.length === 0) {
    warn('视频元素为空');
    return null;
  }
  videos.sort((a, b) => {
    // 优先级 1 播放状态:播放中优先
    // const playingA = isPlaying(a)
    // const playingB = isPlaying(b)
    // if (playingA !== playingB) {
    //   return playingA ? -1 : 1
    // }

    // 优先级 2 音频状态:非静音优先
    const audibleA = isAudible(a);
    const audibleB = isAudible(b);
    if (audibleA !== audibleB) {
      return audibleA ? -1 : 1;
    }
    const rectA = a.getBoundingClientRect();
    const rectB = b.getBoundingClientRect();

    // 优先级 3 元素大小:大尺寸优先
    const sizeA = rectA.width * rectA.height;
    const sizeB = rectB.width * rectB.height;
    // 允许 100 像素的误差视为相等,或者直接比较
    if (sizeA !== sizeB) {
      return sizeB - sizeA;
    }

    // 优先级 4 视口距离:距离视口中心越近越优先 (距离越小越好)
    const distA = getDistanceFromViewportCenter(rectA);
    const distB = getDistanceFromViewportCenter(rectB);
    return distA - distB;
  });
  warn(videos);

  // 返回排序后的第一个元素,即最优匹配
  return videos[0];
}

/**
 * 检测当前活动元素是否为输入元素
 */
function isInputActive() {
  let activeElement = document.activeElement;
  if (!activeElement) return false;
  while (activeElement.shadowRoot?.activeElement) {
    activeElement = activeElement.shadowRoot.activeElement;
  }
  const tagName = activeElement.tagName;
  return tagName === 'INPUT' || tagName === 'TEXTAREA' || activeElement instanceof HTMLElement && activeElement.isContentEditable;
}
;// ./src/scripts/playback-rate/index.ts




// 由于 sohu 阻止了键盘事件,需要在捕获阶段监听

new class PlaybackRateController {
  /** 触发按键 */
  triggerKeys = ['ArrowLeft', 'ArrowRight'];
  /** 按键次数 -> 倍速 映射 */
  rateMap = {
    1: 3,
    2: 6,
    3: 9
  };
  currentTriggerKey = null;
  videoPlaybackRate = 1;
  /** 是否正在倍速播放 */
  isBoosting = false;
  /** 当前视频元素 */
  _video = null;

  /** 当前视频元素 */
  get video() {
    return this._video;
  }
  set video(video) {
    // 处于倍速时不能置空视频元素,否则播放速度无法恢复
    if (this.isBoosting && video === null) {
      return;
    }
    this._video = video;
  }

  /**
   * 判断当前是否处于输入状态,
   * 如果是,不处理任何快捷键。避免冲突,比如输入状态下按方向键。
   */
  isInputActive = false;
  constructor() {
    this.multiPress = createMultiPress({
      pressInterval: 100,
      longPressThreshold: 200,
      enableRepeat: true,
      onKeydown: event => {
        /**
         * 按下方向键时如果有视频元素,则阻止网站本身行为
         */
        if (!this.triggerKeys.includes(event.key)) return;

        // 只在首次按下时获取状态,重复按下时不再获取避免影响性能
        if (event.repeat === false) {
          if (this.isInputActive = isInputActive()) return;
          this.video ??= findBestVideoElement();
        }
        if (this.isInputActive) return;
        if (this.video) {
          event.stopPropagation();
          event.stopImmediatePropagation();
          event.preventDefault();
        }

        // ← ArrowLeft
        // 这里无多击判断延迟
        if (event.key === 'ArrowLeft') {
          this.handleSeek('backward');
        }
      },
      onKeyup: event => {
        // 松开方向键时如果有视频元素,则阻止网站本身行为
        // 虽然 keyup 不一定需要停止传播,但为了逻辑一致性避免页面响应 keyup
        if (this.triggerKeys.includes(event.key) && this.video) {
          event.stopPropagation();
          event.stopImmediatePropagation();
          event.preventDefault();
        }
        this.handleKeyUp(event);
      }
    });
    this.init();
  }
  init() {
    // → ArrowRight
    for (const pressCount of Object.keys(this.rateMap)) {
      this.multiPress.on('ArrowRight', Number(pressCount), event => {
        if (event.isRepeat || this.isInputActive) return;
        if (event.isLongPress) {
          this.handleSpeed(event);
        } else {
          this.handleSeek('forward');
        }
      });
    }
    this.multiPress.start();
  }
  handleSpeed(event) {
    warn('speed');
    if (this.isBoosting || !event.isLongPress) return;
    const {
      video
    } = this;
    if (!video) return;
    this.isBoosting = true;
    this.currentTriggerKey = event.key;
    this.videoPlaybackRate = video.playbackRate;
    video.playbackRate = this.rateMap[event.pressCount] ?? this.videoPlaybackRate;
  }
  handleKeyUp(event) {
    if (this.isBoosting && event.key === this.currentTriggerKey) {
      warn('恢复播放速度');
      this.video.playbackRate = this.videoPlaybackRate;
      this.isBoosting = false;
      this.currentTriggerKey = null;
      this.video = null;
    }
  }

  /** 前进或后退 */
  handleSeek(direction = 'forward') {
    warn('seek');
    const {
      video
    } = this;
    if (video) {
      video.currentTime += direction === 'forward' ? 5 : -5;
    }
    this.video = null;
  }
  destroy() {
    this.multiPress.stop();
  }
}();
/******/ })()
;