video play util

Universal video control. Auto-picks the video that is really playing with sound, then advance / retract / multiply speed.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         video play util
// @namespace    https://gist.github.com/zqcccc/32eb827d35b8134f32f9dd3f70d9fb26
// @version      0.0.17
// @description  Universal video control. Auto-picks the video that is really playing with sound, then advance / retract / multiply speed.
// @author       zqcccc
// @match        https://www.disneyplus.com/*
// @match        https://www.bilibili.com/video/*
// @match        https://jable.tv/videos/*
// @match        https://dsxys.live/player/*
// @match        https://web.telegram.org/k/*
// @match        https://www.youtube.com/watch*
// @match        https://kaiwu.lagou.com/*
// @match        https://ddys.pro/*
// @match        https://v.qq.com/x/*
// @match        https://frontendmasters.com/courses/*
// @include      /^https:\/\/ddys.+$/
// @match        https://www.youtube.com/*
// @match        https://www.iyf.tv/*
// @match        https://www.iole.tv/vodplay/*
// @match        https://sxyprn.com/post/*
// @match        https://4kvm.net/*
// @include      /^https:\/\/(www.)?4kvm.+$/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        none
// @license      MIT
// ==/UserScript==

; (function () {
  'use strict'

  // ---------------------------------------------------------------------------
  // 目标视频:自动挑选「当前正在播放 + 有声音」的那一个
  //
  // 打分顺序(全部实时求值,不长期缓存元素):
  //   1. 真的在播(!paused && !ended,currentTime 在推进)
  //   2. 真的在出声(!muted && volume > 0)—— 静音的一律不要
  //   3. 盖在最上面的(z-index 最大 / DOM 里最靠后)= 用户正在看的那个
  //   4. 画中画 / 全屏里的那个直接胜出
  //   5. 可见面积大、时长长的兜底
  //
  // 内存:选中的元素用 WeakRef 持有(不阻止被回收),每轮先验证还在 DOM 里;
  //       observer / 定时器 / 事件监听全部登记在 handles 里,init() 先清空再重建,
  //       反复点刷新也不会叠出一堆 MutationObserver。
  // ---------------------------------------------------------------------------

  const PANEL_ID = 'panel-control'
  const SILENT_EPS = 0.001

  const state = {
    videoRef: null,        // WeakRef<HTMLVideoElement> | HTMLVideoElement
    lastVideoCount: 0,
    lastPickAt: 0
  }

  const prevTimes = new WeakMap()   // video -> 上一轮 currentTime,用于判断「真的在推进」

  // --- 生命周期句柄:每次 init() 前全部清空,杜绝重复注册 ---------------------
  let handles = []
  function addRemove(fn) {
    if (typeof fn === 'function') handles.push(fn)
  }
  function emptyHandles() {
    for (let i = handles.length - 1; i >= 0; i--) {
      try { handles[i]() } catch (e) { /* 单个清理失败不能中断其它清理 */ }
    }
    handles.length = 0
  }

  // --- WeakRef 兼容(老浏览器退化成强引用,行为不变) -------------------------
  const canWeakRef = typeof WeakRef === 'function'

  function setPicked(el) {
    state.videoRef = el ? (canWeakRef ? new WeakRef(el) : el) : null
  }
  function getPicked() {
    if (!state.videoRef) return null
    const el = canWeakRef ? state.videoRef.deref() : state.videoRef
    if (el && el.isConnected && el.tagName === 'VIDEO') return el
    state.videoRef = null
    return null
  }

  // --- 判定工具 -------------------------------------------------------------

  // 有声音:没静音、音量不为 0
  function hasSound(v) {
    if (v.muted) return false
    return v.volume > SILENT_EPS
  }

  // 便宜的就地隐藏判断,先筛掉不可见的,再对少数候选做昂贵计算
  function isHidden(v) {
    if (v.offsetWidth === 0 && v.offsetHeight === 0) return true
    const r = v.getBoundingClientRect()
    if (r.width <= 0 || r.height <= 0) return true
    return false
  }

  // 谁在最上层(z-index 大 / 定位祖先多 / DOM 里靠后)谁就是当前在看的
  function stackingScore(el) {
    let score = 0
    let node = el
    for (let i = 0; i < 8 && node && node.nodeType === 1; i++) {
      const st = window.getComputedStyle(node)
      if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0) {
        return -1e9
      }
      if (st.position !== 'static') {
        const z = parseInt(st.zIndex, 10)
        score = Math.max(score, Number.isFinite(z) ? z + 1000 : 1000)
      }
      node = node.parentElement
    }
    const all = document.querySelectorAll('video')
    score += Array.prototype.indexOf.call(all, el)
    return score
  }

  function visibleArea(v) {
    const r = v.getBoundingClientRect()
    if (r.width <= 0 || r.height <= 0) return 0
    const w = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0))
    const h = Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0))
    return w * h
  }

  // 真的在推进:currentTime 动过,或者已解码到可播放帧
  function isProgressing(v, prevTime) {
    if (v.paused || v.ended) return false
    if (prevTime != null && v.currentTime !== prevTime) return true
    return v.readyState >= 2
  }

  // --- 核心:自动挑选 -------------------------------------------------------

  function pickVideoElement() {
    const list = Array.from(document.querySelectorAll('video')).filter(
      (v) => v.isConnected
    )
    state.lastVideoCount = list.length
    if (!list.length) {
      setPicked(null)
      return null
    }

    // 画中画里的那个就是用户明确挑中的主视频,直接用它
    const pip = document.pictureInPictureElement
    if (pip && pip.isConnected && pip.tagName === 'VIDEO') {
      setPicked(pip)
      return pip
    }

    const fsRoot = document.fullscreenElement || null

    // 1) 有声音 + 没播完 + 可见 —— 这才是「当前正在播放的那个」
    const sounding = list.filter((v) => hasSound(v) && !v.ended && !isHidden(v))

    // 2) 一个有声的都没有(例如刚进页面还没解除静音)时降级:至少别报错
    const pool = sounding.length ? sounding : list

    // 3) 候选太多(首页瀑布流能有几十个 <video>)时先按可见面积粗排,
    //    只对前 12 个做昂贵的层级计算,避免每秒几百次 getComputedStyle
    let candidates = pool
    if (candidates.length > 12) {
      candidates = candidates.slice().sort((a, b) => visibleArea(b) - visibleArea(a)).slice(0, 12)
    }

    let best = null
    let bestScore = -Infinity
    for (const v of candidates) {
      const prev = prevTimes.get(v)
      prevTimes.set(v, v.currentTime)

      let score = 0
      if (fsRoot && fsRoot.contains(v)) score += 15000    // 全屏中
      if (isProgressing(v, prev)) score += 10000       // 正在播 = 最强信号
      else if (!v.paused && !v.ended) score += 8000
      else if (v.readyState >= 1) score += 1000

      if (hasSound(v)) score += 5000
      if (sounding.length) score += 3000

      score += Math.min(Math.max(stackingScore(v), -3000), 3000) / 100  // 上层优先
      score += Math.min(visibleArea(v) / 1000, 2000)                   // 大屏优先
      if (Number.isFinite(v.duration) && v.duration > 0) {
        score += Math.min(v.duration, 60) / 60
      }

      if (score > bestScore) {
        bestScore = score
        best = v
      }
    }

    setPicked(best)
    state.lastPickAt = Date.now()
    return best
  }

  function getVideoElement() {
    document.documentElement.style.background = '#fff'
    const picked = getPicked()
    if (picked) return picked
    return pickVideoElement()
  }

  // --- 播放控制 -------------------------------------------------------------

  function adjustPlaybackRate(videoElement, change) {
    if (!videoElement) return
    // 计算新的播放速度并四舍五入到1位小数
    const newRate = Math.round((videoElement.playbackRate + change) * 10) / 10
    videoElement.playbackRate = newRate
  }

  function attachKeydownEvent() {
    function keydownHandle(e) {
      // 正在输入框里打字就别抢快捷键
      const t = e.target
      if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return

      const videoElement = getVideoElement()
      if (!videoElement) return

      let handled = true
      switch (e.code) {
        case 'Period':
        case 'Quote':      // 单引号键
          videoElement.currentTime += 1
          break
        case 'Comma':
        case 'Semicolon':  // 分号键
          videoElement.currentTime -= 2
          break
        case 'Equal':
          adjustPlaybackRate(videoElement, 0.1)
          break
        case 'Minus':
          adjustPlaybackRate(videoElement, -0.1)
          break
        default:
          handled = false
          break
      }
      if (handled) e.preventDefault()
    }

    document.documentElement.addEventListener('keydown', keydownHandle)
    return () => document.documentElement.removeEventListener('keydown', keydownHandle)
  }

  // 监听整个 document:SPA 里的 video 随时被增删,绑死在单个元素上必然失效
  function observeVideo() {
    let scheduled = false
    const onDirty = () => {
      if (scheduled) return
      scheduled = true
      queueMicrotask(() => {
        scheduled = false
        if (!getPicked()) {
          pickVideoElement()
          refreshPanelInfo()
        }
      })
    }

    const ob = new MutationObserver(onDirty)
    ob.observe(document.documentElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['src', 'class', 'style']
    })
    return () => ob.disconnect()
  }

  // 周期性校准:paused / volume / muted / src 的变化不产生 DOM mutation
  function startWatcher() {
    let ticks = 0
    const timer = setInterval(() => {
      ticks++
      const picked = getPicked()
      const count = document.querySelectorAll('video').length

      // 选中的没了 / 视频数量变了 / 每 4 秒兜底重挑一次
      if (!picked || count !== state.lastVideoCount || ticks % 4 === 0) {
        const before = picked
        const next = pickVideoElement()
        if (next !== before) refreshPanelInfo()
      }
      if (ticks % 2 === 0) refreshPanelInfo()
    }, 1000)
    return () => clearInterval(timer)
  }

  // --- 面板 -----------------------------------------------------------------

  function getVideoInfoText(video) {
    const { src, duration, currentTime, playbackRate, paused, muted, volume } = video
    return `
    <div>src: ${src}</div>
    <div>playbackRate: ${playbackRate}</div>
    <div>duration: ${Number.isFinite(duration) ? duration.toFixed(1) : duration}</div>
    <div>currentTime: ${currentTime.toFixed(1)}</div>
    <div>paused: ${paused} / muted: ${muted} / volume: ${volume}</div>
    <div>页面视频数: ${state.lastVideoCount}</div>
    `
  }

  function refreshPanelInfo() {
    const panel = document.getElementById(PANEL_ID)
    if (!panel) return
    const info = panel.querySelector('.info')
    if (!info) return
    const video = getVideoElement()
    info.innerHTML = video ? getVideoInfoText(video) : '未找到视频'
  }

  function insertPanel() {
    document.getElementById(PANEL_ID)?.remove()

    const panel = document.createElement('div')
    panel.style.cssText = `
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: fixed;
z-index: 2147483000;
width: 200px;
height: 200px;
background: rgba(0,0,0,0.4);
color: #fff;
overflow: auto;
`
    panel.style.left = '0'
    panel.style.top = '50vh'
    panel.draggable = 'true'
    panel.setAttribute('id', PANEL_ID)

    panel.innerHTML = `<style>
#panel-control { opacity: 0.1 }
#panel-control:hover { opacity: 1 }
#panel-control button {
  background: rgba(0,0,0,0.3);
  border: none;
  color: #fff;
  cursor: pointer;
}
#refresh-panel {
  user-select: none;
  cursor: pointer;
  position: absolute;
  right: 10px;
  top: 10px;
}
#panel-control .info {
  font-size: 10px;
  word-break: break-all;
  max-width: 190px;
  text-align: left;
}
</style>
<div class="info"></div>
<button class="get-info">info</button>
<button class="add-speed">speed+0.1</button>
<button class="minus-speed">speed-0.1</button>
<button class="forward">+1s</button>
<button class="backward">-2s</button>
<button class="re-pick">重新选择</button>
<button id="refresh-panel">↻</button>
`
    panel.querySelector('.get-info')?.addEventListener('click', refreshPanelInfo)
    panel.querySelector('.add-speed')?.addEventListener('click', () => {
      const video = getVideoElement()
      video && adjustPlaybackRate(video, 0.1)
      refreshPanelInfo()
    })
    panel.querySelector('.minus-speed')?.addEventListener('click', () => {
      const video = getVideoElement()
      video && adjustPlaybackRate(video, -0.1)
      refreshPanelInfo()
    })
    panel.querySelector('.forward')?.addEventListener('click', () => {
      const video = getVideoElement()
      video && (video.currentTime += 1)
      refreshPanelInfo()
    })
    panel.querySelector('.backward')?.addEventListener('click', () => {
      const video = getVideoElement()
      video && (video.currentTime -= 2)
      refreshPanelInfo()
    })
    panel.querySelector('.re-pick')?.addEventListener('click', () => {
      setPicked(null)
      pickVideoElement()
      refreshPanelInfo()
    })
    panel.querySelector('#refresh-panel')?.addEventListener('click', () => {
      init()
    })

    let canDrag = false
    let lastX, lastY, panelLeft, panelTop

    panel.addEventListener('dragstart', (e) => {
      canDrag = true
      lastX = e.pageX
      lastY = e.pageY
      const rect = panel.getBoundingClientRect()
      panelLeft = rect.left
      panelTop = rect.top
    })
    panel.addEventListener('drag', (e) => {
      if (canDrag) {
        const disX = e.pageX - lastX
        panel.style.left = panelLeft + disX + 'px'
        const disY = e.pageY - lastY
        panel.style.top = panelTop + disY + 'px'
      }
    })
    panel.addEventListener('dragend', () => (canDrag = false))
    panel.addEventListener('dragover', (e) => e.preventDefault())

    document.documentElement.appendChild(panel)
  }

  // --- 启动 -----------------------------------------------------------------

  function init() {
    emptyHandles()

    addRemove(observeVideo())
    addRemove(attachKeydownEvent())
    addRemove(startWatcher())

    // 只挂一次:用户主动暂停/播放时标记一下,避免自动挑选跟用户对着干
    if (!init._wired) {
      init._wired = true
      const onMedia = (e) => {
        const v = e.target
        if (v && v.tagName === 'VIDEO') prevTimes.delete(v)
      }
      document.addEventListener('play', onMedia, true)
      document.addEventListener('pause', onMedia, true)
      document.addEventListener('volumechange', onMedia, true)
      addRemove(() => {
        document.removeEventListener('play', onMedia, true)
        document.removeEventListener('pause', onMedia, true)
        document.removeEventListener('volumechange', onMedia, true)
        init._wired = false
      })
    }

    pickVideoElement()
    insertPanel()
    refreshPanelInfo()
  }

  init()
  console.log('init player util')
  console.log('%c init player util: ', 'font-size:12px;background-color:#3F7CFF;color:#fff;', 'hello ')

})()