video play util

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         video play util
// @namespace    https://gist.github.com/zqcccc/32eb827d35b8134f32f9dd3f70d9fb26
// @version      0.0.19
// @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
  }

  // 挑选模式:
  //   'sound'  —— 严格模式,只认「正在播 + 有声音」的(默认,符合本脚本的定位)
  //   'any'    —— 宽松模式,用户手动点过「允许静音」后才用,允许选静音视频
  // 历史教训:早先无条件回退到「全部 video」,结果页面上没有有声视频时(比如
  // 摘挂中的瞬间、缩略图瀑布流),会随手选中一个静音缩略图——用户按加速键,
  // 改的却是缩略图的速度,比什么都不做更糟。
  const MODE = { SOUND: 'sound', ANY: 'any' }
  let pickMode = MODE.SOUND

  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 里靠后)谁就是当前在看的。
  // allVideos 由调用方传入,避免每个候选都重跑一次 querySelectorAll(O(n²))。
  function stackingScore(el, allVideos) {
    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
    }
    score += Array.prototype.indexOf.call(allVideos || [], 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
  }

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

  // 安全阀:任何原因导致 pickVideoElement 被高频重入(历史 bug:MutationObserver
  // 自激循环),最多每秒 30 次,超出直接放弃这一轮。宁可这一秒选不准,
  // 也绝不允许把页面主线程占死——那会让整页打不开。
  //
  // 注意:用户主动操作(init / 重新选择)会先 resetPickThrottle(),
  // 不会因为「连点刷新 60 下」而被限流掉——安全阀只针对失控的自动路径。
  let pickWindowStart = 0
  let pickCount = 0
  let pickThrottleWarned = false

  function resetPickThrottle() {
    pickWindowStart = 0
    pickCount = 0
  }

  function pickVideoElement() {
    const now = Date.now()
    if (now - pickWindowStart > 1000) {
      pickWindowStart = now
      pickCount = 0
    }
    if (++pickCount > 30) {
      if (!pickThrottleWarned) {
        pickThrottleWarned = true
        console.warn('[video play util] 自动挑选触发过频,已限流;这通常意味着有循环调用')
      }
      return getPicked()
    }
    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) 严格模式下没有有声视频就什么都不选。
    //    (不要回退到「随便挑一个」:用户按加速键时改到静音缩略图上,比不响应更糟)
    //    只在用户显式允许后才走宽松模式。
    let pool
    if (sounding.length) {
      pool = sounding
    } else if (pickMode === MODE.ANY) {
      pool = list.filter((v) => !v.ended && !isHidden(v))
      if (!pool.length) pool = list
    } else {
      setPicked(null)
      return null
    }

    // 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, list), -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)
  }

  // 监听 video 的增删与换源。
  //
  // 历史教训(0.0.17 的致命 bug):这里曾经是
  //   observe(document.documentElement, { subtree:true, attributes:true, attributeFilter:['src','class','style'] })
  // 而回调里会调用 refreshPanelInfo() 写面板 innerHTML——面板就在被观察的子树里,
  // 于是「回调改 DOM → 触发回调 → 再改 DOM」形成自激循环。页面没有 video 时
  // getPicked() 恒为 null,那道 if 根本拦不住,主线程被彻底占死:
  // 页面加载初期本来就没有 video,所以脚本一注入就把页面卡死(表现为“页面打不开”)。
  //
  // 现在两条硬约束:
  //   1. 只观察 video 元素的增删(childList),不观察属性;
  //   2. 面板自身的变更一律忽略,绝不让回调写回被观察子树。
  // 属性变化(src / paused / volume)交给下面低频轮询处理。
  function observeVideo() {
    let scheduled = false
    const onDirty = () => {
      if (scheduled) return
      scheduled = true
      queueMicrotask(() => {
        scheduled = false
        if (getPicked()) return
        const next = pickVideoElement()
        if (next) refreshPanelInfo()
      })
    }

    const ob = new MutationObserver(onDirty)
    ob.observe(document.documentElement, { childList: true, subtree: true })
    return () => ob.disconnect()
  }

  // 周期性校准:paused / muted / volume / src 的变化不产生 childList 变更,
  // 只能靠轮询。每 4 秒重挑一次,且只在候选真的变了才写面板。
  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()
      }
      // 面板里的进度/速度是活的,别让它一直是旧值;
      // 但只在面板存在时才写,且面板的变更不会再触发观察器(见 observeVideo)
      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()
    let html
    if (video) {
      html = getVideoInfoText(video)
    } else if (state.lastVideoCount === 0) {
      html = '未找到视频'
    } else {
      // 页面上有 video,但没有一个「正在播 + 有声音」的。说清楚原因,
      // 并告诉用户可以主动切宽松模式,而不是默默挑个静音的。
      html = `页面上有 ${state.lastVideoCount} 个 video,但没有正在播放且有声音的<br>
      (可能已暂停 / 静音 / 未开始播)<br>
      <button class="force-silent">允许静音</button>`
    }
    // 内容没变就别写 DOM:写面板本身就是一次 DOM 变更,无谓地写会白白惊动观察器
    if (info.__vpuHtml === html) return
    info.__vpuHtml = html
    info.innerHTML = html
    info.querySelector('.force-silent')?.addEventListener('click', () => {
      pickMode = MODE.ANY
      setPicked(null)
      pickVideoElement()
      refreshPanelInfo()
    })
  }

  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', () => {
      pickMode = MODE.SOUND      // 重新选择 = 回到严格的「有声」模式
      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()
    resetPickThrottle()   // 用户主动触发的,给足预算

    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 ')

})()