// ==UserScript==
// @name Precise video playback (YouTube)
// @name:zh-CN 精确控制视频播放进度 (YouTube)
// @description A toolbar to set precise video play time and generate clip script
// @description:zh-CN 精确控制视频播放进度/生成剪辑脚本的工具栏
// @homepage https://github.com/suisei-cn/pvp
// @namespace moe.suisei.pvp.youtube
// @match https://www.youtube.com/*
// @match https://youtube.com/*
// @grant none
// @version 0.7.1
// @author Outvi V
// ==/UserScript==
'use strict'
let control
console.log('Precise Video Playback is up. Watching for video players...')
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'
})
btnContainer.append(btnJump, btnRemove)
return btnContainer
}
function getVideoId (url) {
return String(url).match(/v=([^&#]+)/)[1]
}
function applyStyle (elem, styles) {
for (const [key, value] of Object.entries(styles)) {
elem.style[key] = value
}
}
function parseTime (str) {
if (!isNaN(Number(str))) return Number(str)
const time = str.match(/([0-9]?)?:([0-9]+)(\.([0-9]+))?/)
if (time === null) return -1
const ret =
Number(time[1] || 0) * 60 + Number(time[2]) + Number(time[4] || 0) * 0.1
if (isNaN(ret)) return -1
return ret
}
function generateControl () {
const app = document.createElement('div')
const cutBar = document.createElement('div')
const inputFrom = document.createElement('input')
inputFrom.placeholder = 'from time'
const inputTo = document.createElement('input')
inputTo.placeholder = 'to time'
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: '700px',
marginTop: '15px',
marginLeft: 'auto',
marginRight: 'auto'
})
applyStyle(cutBar, {
display: 'flex',
flexWrap: 'wrap',
marginTop: '1.5vh'
})
applyStyle(currentTime, {
fontSize: '1.3rem',
minWidth: '8.1rem',
textAlign: 'center',
color: 'var(--yt-spec-text-primary)'
})
const inputCommonStyle = {
width: '120px'
}
applyStyle(inputFrom, inputCommonStyle)
applyStyle(inputTo, inputCommonStyle)
btn.innerText = 'Repeat play'
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
}
}
function generateFullControl (videoElement) {
const control = generateControl()
// 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)
)
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.inputTo.placeholder = `to ${toValue.toFixed(2)}`
return
}
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(`ffmpeg -i $(youtube-dl -f bestaudio -g "https://www.youtube.com/watch?v=${videoId}") \
-ss ${fromValue} \
-to ${toValue} \
-acodec libmp3lame \
-ab 192k \
-af loudnorm=I=-16:TP=-2:LRA=11 \
-vn \
output-${videoId}-${fromValue}-${toValue}.mp3`)
})
return control
}
function keepControl () {
if (!String(window.location).includes('/watch?')) return
if (!control || control.app.offsetHeight === 0) {
console.log(
'New video playback page found. Trying to insert the widget...'
)
const video = document.querySelector('video')
const anchor = document.querySelector('ytd-video-primary-info-renderer')
if (!video || !anchor) return
console.log('Video and anchor found. Releasing the widget...')
control = generateFullControl(video)
anchor.parentElement.insertBefore(control.app, anchor)
anchor.parentElement.insertBefore(control.cutBar, anchor)
console.log('The widget is up.')
}
}
keepControl()
setInterval(keepControl, 1000)