video play util

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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 呼出面板')
})()