Universal video control. Auto-picks the video that is really playing with sound, then advance / retract / multiply speed.
// ==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 ')
})()