// ==UserScript==
// @name Precise video playback (Bilibili)
// @name:zh-CN 精确控制视频播放进度 (Bilibili)
// @description A toolbar to set precise video play time and generate clip script
// @description:zh-CN 精确控制视频播放进度/生成剪辑脚本的工具栏
// @namespace moe.suisei.pvp.bilibili
// @match https://bilibili.com/video/*
// @match https://www.bilibili.com/video/*
// @grant none
// @version 0.7.6
// @author Outvi V
// ==/UserScript==
'use strict'
function collectCutTiming(cutBar) {
return [...cutBar.querySelectorAll('div > button:nth-child(1)')].map((x) =>
Number(x.innerText)
)
}
function createCutButton(time, videoElement) {
const btnJump = document.createElement('button')
const btnRemove = document.createElement('button')
const btnContainer = document.createElement('div')
btnJump.innerText = time
btnRemove.innerText = 'x'
btnJump.addEventListener('click', () => {
videoElement.currentTime = time
})
btnRemove.addEventListener('click', () => {
btnContainer.style.display = 'none'
})
applyStyle(btnContainer, {
marginRight: '0.5vw',
flexShrink: '0',
marginTop: '3px',
})
btnContainer.append(btnJump, btnRemove)
return btnContainer
}
console.log('Precise Video Playback is up')
function getVideoId(url) {
return String(url).match(/(a|b)v([^?&#]+)/i)[0]
}
function applyStyle(elem, styles) {
for (const [key, value] of Object.entries(styles)) {
elem.style[key] = value
}
}
function parseTime(str) {
const hms = str.split(':')
let time = 0
for (const i of hms) {
time *= 60
time += Number(i)
if (isNaN(time)) return -1
}
return time
}
function generateControl() {
const app = document.createElement('div')
const cutBar = document.createElement('div')
const inputFrom = document.createElement('input')
inputFrom.placeholder = 'from 0'
const inputTo = document.createElement('input')
inputTo.placeholder = 'to ...'
const currentTime = document.createElement('span')
const btn = document.createElement('button')
const btnStop = document.createElement('button')
const btnExport = document.createElement('button')
const btnCut = document.createElement('button')
applyStyle(app, {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
maxWidth: '600px',
marginTop: '15px',
marginLeft: 'auto',
marginRight: 'auto',
})
applyStyle(cutBar, {
display: 'flex',
flexWrap: 'wrap',
marginTop: '1vh',
})
applyStyle(currentTime, {
fontSize: '1.2rem',
minWidth: '7.5rem',
textAlign: 'center',
})
const inputCommonStyle = {
width: '80px',
}
applyStyle(inputFrom, inputCommonStyle)
applyStyle(inputTo, inputCommonStyle)
btn.innerText = 'Jump'
btnStop.innerText = 'Stop'
btnExport.innerText = 'Export'
btnCut.innerText = 'Cut'
app.appendChild(inputFrom)
app.appendChild(inputTo)
app.appendChild(currentTime)
app.appendChild(btn)
app.appendChild(btnStop)
app.appendChild(btnExport)
app.appendChild(btnCut)
return {
app,
cutBar,
inputFrom,
inputTo,
currentTime,
btn,
btnStop,
btnExport,
btnCut,
}
}
async function sleep(time) {
await new Promise((resolve) => {
setTimeout(() => {
resolve()
}, time)
})
}
async function waitfor(cb) {
for (;;) {
if (cb()) return
await sleep(500)
}
}
async function main() {
console.log('Waiting for the page...')
// Wait for Bilibili to fully render the page
// Or error could occur after inserting our widget
await waitfor(() => {
return (
document.querySelector('#v_upinfo .up-face') ||
document.querySelector('#member-container .avatar')
)
})
console.log('Pre-install hook OK')
// Player fetching
console.log('Waiting for the player...')
let anchor
while (true) {
anchor = document.querySelector('#v_desc')
if (anchor && !anchor.hidden) break
await sleep(500)
}
const videoElement = document.querySelector('video')
if (!videoElement || !anchor) {
console.warn('Player not found. Exiting.')
return
}
console.log('Player detected.')
// Layout
const control = generateControl()
const shadowParent = document.createElement('div')
const shadow = shadowParent.attachShadow({ mode: 'open' })
anchor.parentElement.insertBefore(shadowParent, anchor)
anchor.parentElement.insertBefore(control.cutBar, anchor)
shadow.appendChild(control.app)
// States
let fromValue = 0
let toValue = 0
// Initial state update attempt
const urlTime = window.location.hash.match(
/#pvp([0-9]+\.?[0-9]?)-([0-9]+\.?[0-9]?)/
)
if (urlTime !== null) {
console.log('Attempting to recover time from URL...')
control.inputFrom.value = fromValue = Number(urlTime[1]) || 0
control.inputTo.value = toValue = Number(urlTime[2]) || 0
}
// Current playback time
function updateCurrentTime() {
control.currentTime.innerText = Number(videoElement.currentTime).toFixed(2)
requestAnimationFrame(updateCurrentTime)
}
requestAnimationFrame(updateCurrentTime)
// Repeat playback
function onTimeUpdate() {
if (videoElement.currentTime >= Number(toValue)) {
videoElement.currentTime = Number(fromValue)
}
}
control.btn.addEventListener('click', (evt) => {
evt.preventDefault()
videoElement.pause()
videoElement.currentTime = fromValue
if (fromValue < toValue) {
videoElement.play()
videoElement.addEventListener('timeupdate', onTimeUpdate)
} else {
videoElement.removeEventListener('timeupdate', onTimeUpdate)
}
})
control.btnStop.addEventListener('click', (evt) => {
evt.preventDefault()
videoElement.removeEventListener('timeupdate', onTimeUpdate)
videoElement.pause()
})
control.btnCut.addEventListener('click', () => {
const nowTime = Number(videoElement.currentTime).toFixed(2)
const btn = createCutButton(nowTime, videoElement)
control.cutBar.append(btn)
})
control.btnCut.addEventListener('contextmenu', (evt) => {
evt.preventDefault()
if (!control.cutBar) return
const timings = collectCutTiming(control.cutBar)
const newTimings = prompt(
'This is your current cut list. Change it to import cut from others.',
JSON.stringify(timings)
)
if (newTimings === null) return
const parsedNewTimings = (() => {
try {
return JSON.parse(newTimings)
} catch {
console.warn('Failed to parse the new cut list.')
return []
}
})()
if (JSON.stringify(timings) === JSON.stringify(parsedNewTimings)) {
console.log('No changes on the cut list.')
return
}
control.cutBar.innerHTML = ''
for (const i of parsedNewTimings) {
const btn = createCutButton(i, videoElement)
control.cutBar.append(btn)
}
})
// Start/end time setting
function updateURL() {
history.pushState(null, null, `#pvp${fromValue}-${toValue}`)
}
control.inputFrom.addEventListener('change', () => {
const input = control.inputFrom.value
if (input === '') {
fromValue = 0
control.inputFrom.placeholder = 'from 0'
return
}
const time = parseTime(input)
if (time === -1) {
control.btn.disabled = true
return
}
control.btn.disabled = false
fromValue = time
updateURL()
})
control.inputTo.addEventListener('change', () => {
const input = control.inputTo.value
if (input === '') {
toValue = videoElement.duration || 0
control.btn.innerText = 'Jump'
return
}
control.btn.innerText = 'Repeat'
const time = parseTime(input)
if (time === -1) {
control.btn.disabled = true
return
}
control.btn.disabled = false
toValue = time
updateURL()
})
// Button export
control.btnExport.addEventListener('click', (evt) => {
evt.preventDefault()
const videoId = getVideoId(window.location)
alert(`youtube-dl -f 0 "https://www.bilibili.com/video/${videoId}" \\
-x --audio-format mp3 --audio-quality 192k \\
--postprocessor-args "-ss ${fromValue} -to ${toValue} -af loudnorm=I=-16:TP=-2:LRA=11" \\
-o "output-%(id)s-${fromValue}-${toValue}.%(ext)s"`)
})
function setInitialDuration(dur) {
control.inputTo.placeholder = `to ${dur.toFixed(2)}`
const input = control.inputTo.value
if (input !== '') return
toValue = dur
}
if (videoElement.duration) {
setInitialDuration(videoElement.duration)
} else {
videoElement.addEventListener('loadedmetadata', () => {
setInitialDuration(videoElement.duration)
})
}
}
main()