☰

video play util

Universal video control. Auto-picks the video that is really playing with sound, then advance / retract / multiply speed. 全站注入,默认连按两次 Shift 呼出面板。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         video play util
// @namespace    https://gist.github.com/zqcccc/32eb827d35b8134f32f9dd3f70d9fb26
// @version      0.1.0
// @description  Universal video control. Auto-picks the video that is really playing with sound, then advance / retract / multiply speed. 全站注入,默认连按两次 Shift 呼出面板。
// @author       zqcccc
// @match        *://*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        none
// @license      MIT
// ==/UserScript==

; (function () {
  'use strict'

  // ---------------------------------------------------------------------------
  // 全站注入 + 按需呼出
  //
  // 脚本在所有页面(含 iframe)加载,但默认什么都不做:不建面板、不挂快捷键、
  // 不跑定时器,只留一个极廉价的 keydown 监听等呼出信号。呼出后才创建面板、
  // 挂上快捷键与观察器;再按一次呼出键(或点面板上的 ✕)全部拆掉,页面恢复原状。
  //
  // 呼出方式由 CFG.trigger 决定:
  //   'doubleShift'(默认)—— 连按两次 Shift;两次之间只要夹了别的按键就不算,
  //                           所以连续输入大写字母不会误触。
  //   'chord'             —— 组合键,默认 Ctrl+Shift+Alt+V。
  //
  // 视频在 iframe 里时:呼出键无论按在哪个框架,都先向上交给顶层,由顶层向下询问
  // 每个框架,面板唯一地开在手里有视频的那一个里(内嵌播放器的站点属于这种情况)。
  // ---------------------------------------------------------------------------

  const CFG = {
    trigger: 'doubleShift',
    doubleShiftWindow: 400,                                          // 两次 Shift 的最大间隔(毫秒)
    chord: { code: 'KeyV', ctrl: true, shift: true, alt: true, meta: false },
    pingTimeout: 250,                                                // 发起方等子框架应答的时长(毫秒)
    routeTtl: 3000,                                                  // 中继框架保留「最优目标」的时长,够后面的 TOGGLE 转发
    ackTimeout: 120                                                  // 子框架等上级应答「我接管了」的时长,超时就地切换
  }

  const PANEL_CLASS = 'vpu-panel'
  const MSG_PING = 'vpu:ping'
  const MSG_PONG = 'vpu:pong'
  const MSG_TOGGLE = 'vpu:toggle'
  const MSG_UP = 'vpu:up'
  const MSG_ACK = 'vpu:ack'
  const SILENT_EPS = 0.001

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

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

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

  // --- 生命周期句柄:呼出时登记,收起时全部执行,杜绝重复注册 -----------------
  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 次,超出直接放弃这一轮。宁可这一秒选不准,
  // 也绝不允许把页面主线程占死——那会让整页打不开。
  //
  // 注意:用户主动操作(呼出 / 重新选择)会先 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)
    return best
  }

  function getVideoElement() {
    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 attachShortcutKeys() {
    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()
        refreshPanelInfo()
      }
    }

    window.addEventListener('keydown', keydownHandle, true)
    return () => window.removeEventListener('keydown', keydownHandle, true)
  }

  // 监听 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 wireMediaEvents() {
    if (wireMediaEvents._wired) return
    wireMediaEvents._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)
      wireMediaEvents._wired = false
    })
  }

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

  let panelEl = null      // 面板元素;null = 还没呼出过
  let panelOpen = false

  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() {
    if (!panelEl) return
    const info = panelEl.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 buildPanel() {
    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.className = PANEL_CLASS

    panel.innerHTML = `<style>
.vpu-panel { opacity: 0.45 }
.vpu-panel:hover { opacity: 1 }
.vpu-panel button {
  background: rgba(0,0,0,0.3);
  border: none;
  color: #fff;
  cursor: pointer;
}
.vpu-panel .close-btn, .vpu-panel .refresh-btn {
  user-select: none;
  cursor: pointer;
  position: absolute;
  top: 10px;
}
.vpu-panel .close-btn { left: 10px; }
.vpu-panel .refresh-btn { right: 10px; }
.vpu-panel .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 class="refresh-btn">↻</button>
<button class="close-btn">✕</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-btn')?.addEventListener('click', restart)
    panel.querySelector('.close-btn')?.addEventListener('click', closePanel)

    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)
    return panel
  }

  // --- 展开 / 收起 ----------------------------------------------------------

  // 挂上全部运行时资源(观察器、快捷键、轮询、媒体事件)
  function startRuntime() {
    resetPickThrottle()   // 用户主动触发的,给足预算
    addRemove(observeVideo())
    addRemove(attachShortcutKeys())
    addRemove(startWatcher())
    wireMediaEvents()
  }

  function openPanel() {
    if (panelOpen) return
    panelOpen = true
    pickMode = MODE.SOUND
    setPicked(null)
    panelEl = buildPanel()
    startRuntime()
    pickVideoElement()
    refreshPanelInfo()
  }

  function closePanel() {
    if (!panelOpen) return
    lastShiftAt = 0      // 收起时清掉双击计数:否则下一次单击 Shift 会被算成第二击,又弹出来
    panelOpen = false
    emptyHandles()
    if (panelEl) {
      panelEl.remove()
      panelEl = null
    }
    setPicked(null)
  }

  // ↻:把运行时资源全部拆掉重挂,清掉可能残留的重复注册
  function restart() {
    if (!panelOpen) return
    emptyHandles()
    startRuntime()
    pickVideoElement()
    refreshPanelInfo()
  }

  // --- 呼出入口 -------------------------------------------------------------
  //
  // 呼出键按在哪个框架由焦点决定,而该显示面板的框架是「手里有视频的那一个」。
  // 所以按键一律先交给顶层,由顶层向下逐层询问,每个框架自报适配分数,面板唯一地
  // 开在分数最高的那个框架里;再按一次走同一条路径关掉。分数里「面板已经开着」
  // 权重最高,保证开与关始终打在同一个框架上。
  //
  // 消息(token 标识同一次询问,贯穿整棵子树):
  //   UP     子框架按键后向上:请顶层来做这次仲裁
  //   PING   顶层逐层向下:你和你后代里,谁最适合承载面板?
  //   PONG   逐层向上:我这边最高的分数,以及那个目标是不是已经开着面板
  //   TOGGLE 沿同一条路径向下:只有被选中的那个框架动手(开或关)

  let lastShiftAt = 0
  let pingSeq = 0
  const pending = new Map()   // token -> { bestScore, bestTarget, bestHasPanel, outstanding, keep, timer, onBest }

  function isTrigger(e) {
    if (CFG.trigger === 'chord') {
      const c = CFG.chord
      return e.code === c.code &&
        e.ctrlKey === !!c.ctrl && e.shiftKey === !!c.shift &&
        e.altKey === !!c.alt && e.metaKey === !!c.meta
    }
    if (e.code !== 'ShiftLeft' && e.code !== 'ShiftRight') {
      lastShiftAt = 0      // 中间夹了别的按键,双击计数作废
      return false
    }
    const now = Date.now()
    const hit = now - lastShiftAt <= CFG.doubleShiftWindow
    lastShiftAt = now
    return hit
  }

  function onTriggerKeydown(e) {
    if (e.repeat) return
    if (!isTrigger(e)) return
    lastShiftAt = 0      // 这一击已经用掉,别让第三次 Shift 又被算成新的一对
    // 双击 Shift 本身没有默认行为,不动它,避免影响页面;组合键则吃掉事件
    if (CFG.trigger === 'chord') {
      e.preventDefault()
      e.stopPropagation()
    }
    if (window.top === window) togglePanel()
    else delegateUp()
  }

  // 呼出键按在子框架里:交给顶层统一仲裁。各框架自己就地切换的话,
  // 两个都握着视频的框架会各自开出一个面板。
  //
  // 父框架未必跑着这个脚本(sandbox 框架、非 http 协议的中间层),那样消息发出去
  // 没人接,按键就被白白吞掉。所以等一个应答,等不到就就地切换。
  function delegateUp() {
    let acked = false
    const onAck = (e) => {
      const d = e.data
      if (d && typeof d === 'object' && d.type === MSG_ACK) {
        acked = true
        window.removeEventListener('message', onAck)
      }
    }
    window.addEventListener('message', onAck)
    try {
      window.parent.postMessage({ type: MSG_UP }, '*')
    } catch (e) { /* 父窗口不可达,交给下面的兜底 */ }
    setTimeout(() => {
      window.removeEventListener('message', onAck)
      if (!acked) togglePanel()
    }, CFG.ackTimeout)
  }

  function childWindows() {
    const out = []
    for (const f of document.querySelectorAll('iframe, frame')) {
      try {
        if (f.contentWindow) out.push(f.contentWindow)
      } catch (e) { /* 忽略取不到窗口的框架 */ }
    }
    return out
  }

  // 这个框架自己有多适合承载面板:
  //   已经开着面板 —— 最高优先,保证「关」打在有面板的那一处,而不是另开一个
  //   手里有正在播放的视频 —— 最符合脚本定位的目标
  //   有 video 元素但没在播(暂停 / 静音)—— 仍然承载,用户需要面板上的「允许静音」
  //   什么都没有 —— 0 分,不参与竞争
  function localHostScore() {
    if (panelOpen) return 1e6
    const v = pickVideoElement()
    if (v) {
      let score = 1e5 + Math.min(visibleArea(v) / 1000, 1000)
      if (hasSound(v)) score += 500
      return score
    }
    if (document.querySelector('video')) return 1e3
    return 0
  }

  // 向下发起一次询问。token 由调用方给出,中继框架沿用上级的 token,
  // 这样整棵子树共享同一个标识,上级发下来的 TOGGLE 才能沿原路找到目标。
  function ask(token, keep, onBest) {
    const kids = childWindows()
    const entry = {
      bestScore: localHostScore(),
      bestTarget: 'self',
      bestHasPanel: panelOpen,
      outstanding: kids.length,
      keep,
      timer: null,
      onBest
    }
    pending.set(token, entry)
    if (!kids.length) {
      settle(token)
      return
    }
    for (const w of kids) {
      try {
        w.postMessage({ type: MSG_PING, token }, '*')
      } catch (e) { /* 框架已卸载,忽略 */ }
    }
    entry.timer = setTimeout(() => settle(token), CFG.pingTimeout)
  }

  function settle(token) {
    const entry = pending.get(token)
    if (!entry) return
    clearTimeout(entry.timer)
    if (entry.keep) {
      // 中继框架要把「最优目标是谁」多留一会儿:上级随后发来的 TOGGLE 还要靠它转发
      entry.timer = setTimeout(() => pending.delete(token), CFG.routeTtl)
    } else {
      pending.delete(token)
    }
    entry.onBest(entry, token)
  }

  // 在本次询问选出的目标上执行切换:目标是自己就地动手,目标在后代就把命令转发下去
  function actOnBest(entry, token) {
    if (entry.bestTarget === 'self') {
      if (entry.bestHasPanel) closePanel()
      else openPanel()
      return
    }
    try {
      entry.bestTarget.postMessage({ type: MSG_TOGGLE, token }, '*')
    } catch (e) { /* 目标框架已卸载,忽略 */ }
  }

  function togglePanel() {
    // 本框架开着面板:直接关,不必询问(面板同时只存在于一个框架,不存在别的候选)
    if (panelOpen) {
      closePanel()
      return
    }
    const token = Date.now().toString(36) + '-' + (++pingSeq) + '-' + Math.random().toString(36).slice(2, 8)
    ask(token, false, (entry, tk) => {
      // 整棵子树都没有视频:面板开在自己这里,面板上会显示「未找到视频」
      if (entry.bestScore <= 0) openPanel()
      else actOnBest(entry, tk)
    })
  }

  // 收到上级询问:汇总自己与后代的分数,把最高的一个报回去。
  // 即使自己这边没戏也要应答(分数报 0),否则上级只能干等到超时。
  function handlePing(e, token) {
    ask(token, true, (entry, tk) => {
      try {
        e.source && e.source.postMessage({
          type: MSG_PONG,
          token: tk,
          score: entry.bestScore,
          hasPanel: entry.bestHasPanel
        }, '*')
      } catch (err) { /* 上级窗口已卸载,忽略 */ }
    })
  }

  // 收到下级的应答:谁分高谁当候选;全部应答到齐就结算,不必等超时
  function handlePong(e, token, score, hasPanel) {
    const entry = pending.get(token)
    if (!entry || entry.outstanding <= 0) return
    entry.outstanding--
    if (score > entry.bestScore) {
      entry.bestScore = score
      entry.bestTarget = e.source || null
      entry.bestHasPanel = !!hasPanel
    }
    if (entry.outstanding === 0) settle(token)
  }

  window.addEventListener('message', (e) => {
    const d = e.data
    if (!d || typeof d !== 'object') return
    if (d.type === MSG_PING) {
      handlePing(e, d.token)
    } else if (d.type === MSG_PONG) {
      handlePong(e, d.token, d.score, d.hasPanel)
    } else if (d.type === MSG_TOGGLE) {
      const entry = pending.get(d.token)
      if (entry) actOnBest(entry, d.token)
    } else if (d.type === MSG_UP) {
      try {
        e.source && e.source.postMessage({ type: MSG_ACK }, '*')
      } catch (err) { /* 来源窗口已卸载,忽略 */ }
      if (window.top === window) togglePanel()
      else delegateUp()
    }
  })

  window.addEventListener('keydown', onTriggerKeydown, true)

  console.log('[video play util] 就绪:连按两次 Shift 呼出面板')
})()