Universal video control. Auto-picks the video that is really playing with sound, then advance / retract / multiply speed. 全站注入,默认连按两次 Shift 呼出面板。
// ==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 呼出面板')
})()