Precise video playback (Bilibili)

A toolbar to set precise video play time and generate clip script

Instalar este script¿?
Script recomendado por el autor

Puede que también te guste Precise video playback (YouTube).

Instalar este script
  1. // ==UserScript==
  2. // @name Precise video playback (Bilibili)
  3. // @name:zh-CN 精确控制视频播放进度 (Bilibili)
  4. // @description A toolbar to set precise video play time and generate clip script
  5. // @description:zh-CN 精确控制视频播放进度/生成剪辑脚本的工具栏
  6. // @namespace moe.suisei.pvp.bilibili
  7. // @match https://bilibili.com/video/*
  8. // @match https://www.bilibili.com/video/*
  9. // @grant none
  10. // @version 0.7.6
  11. // @author Outvi V
  12. // ==/UserScript==
  13.  
  14. 'use strict'
  15.  
  16. function collectCutTiming(cutBar) {
  17. return [...cutBar.querySelectorAll('div > button:nth-child(1)')].map((x) =>
  18. Number(x.innerText)
  19. )
  20. }
  21.  
  22. function createCutButton(time, videoElement) {
  23. const btnJump = document.createElement('button')
  24. const btnRemove = document.createElement('button')
  25. const btnContainer = document.createElement('div')
  26. btnJump.innerText = time
  27. btnRemove.innerText = 'x'
  28. btnJump.addEventListener('click', () => {
  29. videoElement.currentTime = time
  30. })
  31. btnRemove.addEventListener('click', () => {
  32. btnContainer.style.display = 'none'
  33. })
  34. applyStyle(btnContainer, {
  35. marginRight: '0.5vw',
  36. flexShrink: '0',
  37. marginTop: '3px',
  38. })
  39. btnContainer.append(btnJump, btnRemove)
  40. return btnContainer
  41. }
  42.  
  43. console.log('Precise Video Playback is up')
  44.  
  45. function getVideoId(url) {
  46. return String(url).match(/(a|b)v([^?&#]+)/i)[0]
  47. }
  48.  
  49. function applyStyle(elem, styles) {
  50. for (const [key, value] of Object.entries(styles)) {
  51. elem.style[key] = value
  52. }
  53. }
  54.  
  55. function parseTime(str) {
  56. const hms = str.split(':')
  57. let time = 0
  58. for (const i of hms) {
  59. time *= 60
  60. time += Number(i)
  61. if (isNaN(time)) return -1
  62. }
  63. return time
  64. }
  65.  
  66. function generateControl() {
  67. const app = document.createElement('div')
  68. const cutBar = document.createElement('div')
  69. const inputFrom = document.createElement('input')
  70. inputFrom.placeholder = 'from 0'
  71. const inputTo = document.createElement('input')
  72. inputTo.placeholder = 'to ...'
  73. const currentTime = document.createElement('span')
  74. const btn = document.createElement('button')
  75. const btnStop = document.createElement('button')
  76. const btnExport = document.createElement('button')
  77. const btnCut = document.createElement('button')
  78. applyStyle(app, {
  79. display: 'flex',
  80. alignItems: 'center',
  81. justifyContent: 'space-between',
  82. maxWidth: '600px',
  83. marginTop: '15px',
  84. marginLeft: 'auto',
  85. marginRight: 'auto',
  86. })
  87. applyStyle(cutBar, {
  88. display: 'flex',
  89. flexWrap: 'wrap',
  90. marginTop: '1vh',
  91. })
  92. applyStyle(currentTime, {
  93. fontSize: '1.2rem',
  94. minWidth: '7.5rem',
  95. textAlign: 'center',
  96. })
  97. const inputCommonStyle = {
  98. width: '80px',
  99. }
  100. applyStyle(inputFrom, inputCommonStyle)
  101. applyStyle(inputTo, inputCommonStyle)
  102. btn.innerText = 'Jump'
  103. btnStop.innerText = 'Stop'
  104. btnExport.innerText = 'Export'
  105. btnCut.innerText = 'Cut'
  106. app.appendChild(inputFrom)
  107. app.appendChild(inputTo)
  108. app.appendChild(currentTime)
  109. app.appendChild(btn)
  110. app.appendChild(btnStop)
  111. app.appendChild(btnExport)
  112. app.appendChild(btnCut)
  113. return {
  114. app,
  115. cutBar,
  116. inputFrom,
  117. inputTo,
  118. currentTime,
  119. btn,
  120. btnStop,
  121. btnExport,
  122. btnCut,
  123. }
  124. }
  125.  
  126. async function sleep(time) {
  127. await new Promise((resolve) => {
  128. setTimeout(() => {
  129. resolve()
  130. }, time)
  131. })
  132. }
  133.  
  134. async function waitfor(cb) {
  135. for (;;) {
  136. if (cb()) return
  137. await sleep(500)
  138. }
  139. }
  140.  
  141. async function main() {
  142. console.log('Waiting for the page...')
  143. // Wait for Bilibili to fully render the page
  144. // Or error could occur after inserting our widget
  145.  
  146. await waitfor(() => {
  147. return (
  148. document.querySelector('#v_upinfo .up-face') ||
  149. document.querySelector('#member-container .avatar')
  150. )
  151. })
  152. console.log('Pre-install hook OK')
  153.  
  154. // Player fetching
  155. console.log('Waiting for the player...')
  156. let anchor
  157. while (true) {
  158. anchor = document.querySelector('#v_desc')
  159. if (anchor && !anchor.hidden) break
  160. await sleep(500)
  161. }
  162. const videoElement = document.querySelector('video')
  163. if (!videoElement || !anchor) {
  164. console.warn('Player not found. Exiting.')
  165. return
  166. }
  167. console.log('Player detected.')
  168.  
  169. // Layout
  170. const control = generateControl()
  171. const shadowParent = document.createElement('div')
  172. const shadow = shadowParent.attachShadow({ mode: 'open' })
  173. anchor.parentElement.insertBefore(shadowParent, anchor)
  174. anchor.parentElement.insertBefore(control.cutBar, anchor)
  175. shadow.appendChild(control.app)
  176.  
  177. // States
  178. let fromValue = 0
  179. let toValue = 0
  180.  
  181. // Initial state update attempt
  182. const urlTime = window.location.hash.match(
  183. /#pvp([0-9]+\.?[0-9]?)-([0-9]+\.?[0-9]?)/
  184. )
  185. if (urlTime !== null) {
  186. console.log('Attempting to recover time from URL...')
  187. control.inputFrom.value = fromValue = Number(urlTime[1]) || 0
  188. control.inputTo.value = toValue = Number(urlTime[2]) || 0
  189. }
  190.  
  191. // Current playback time
  192. function updateCurrentTime() {
  193. control.currentTime.innerText = Number(videoElement.currentTime).toFixed(2)
  194. requestAnimationFrame(updateCurrentTime)
  195. }
  196. requestAnimationFrame(updateCurrentTime)
  197.  
  198. // Repeat playback
  199. function onTimeUpdate() {
  200. if (videoElement.currentTime >= Number(toValue)) {
  201. videoElement.currentTime = Number(fromValue)
  202. }
  203. }
  204.  
  205. control.btn.addEventListener('click', (evt) => {
  206. evt.preventDefault()
  207. videoElement.pause()
  208. videoElement.currentTime = fromValue
  209. if (fromValue < toValue) {
  210. videoElement.play()
  211. videoElement.addEventListener('timeupdate', onTimeUpdate)
  212. } else {
  213. videoElement.removeEventListener('timeupdate', onTimeUpdate)
  214. }
  215. })
  216.  
  217. control.btnStop.addEventListener('click', (evt) => {
  218. evt.preventDefault()
  219. videoElement.removeEventListener('timeupdate', onTimeUpdate)
  220. videoElement.pause()
  221. })
  222.  
  223. control.btnCut.addEventListener('click', () => {
  224. const nowTime = Number(videoElement.currentTime).toFixed(2)
  225. const btn = createCutButton(nowTime, videoElement)
  226. control.cutBar.append(btn)
  227. })
  228.  
  229. control.btnCut.addEventListener('contextmenu', (evt) => {
  230. evt.preventDefault()
  231. if (!control.cutBar) return
  232. const timings = collectCutTiming(control.cutBar)
  233. const newTimings = prompt(
  234. 'This is your current cut list. Change it to import cut from others.',
  235. JSON.stringify(timings)
  236. )
  237. if (newTimings === null) return
  238. const parsedNewTimings = (() => {
  239. try {
  240. return JSON.parse(newTimings)
  241. } catch {
  242. console.warn('Failed to parse the new cut list.')
  243. return []
  244. }
  245. })()
  246. if (JSON.stringify(timings) === JSON.stringify(parsedNewTimings)) {
  247. console.log('No changes on the cut list.')
  248. return
  249. }
  250. control.cutBar.innerHTML = ''
  251. for (const i of parsedNewTimings) {
  252. const btn = createCutButton(i, videoElement)
  253. control.cutBar.append(btn)
  254. }
  255. })
  256.  
  257. // Start/end time setting
  258. function updateURL() {
  259. history.pushState(null, null, `#pvp${fromValue}-${toValue}`)
  260. }
  261. control.inputFrom.addEventListener('change', () => {
  262. const input = control.inputFrom.value
  263. if (input === '') {
  264. fromValue = 0
  265. control.inputFrom.placeholder = 'from 0'
  266. return
  267. }
  268. const time = parseTime(input)
  269. if (time === -1) {
  270. control.btn.disabled = true
  271. return
  272. }
  273. control.btn.disabled = false
  274. fromValue = time
  275. updateURL()
  276. })
  277. control.inputTo.addEventListener('change', () => {
  278. const input = control.inputTo.value
  279. if (input === '') {
  280. toValue = videoElement.duration || 0
  281. control.btn.innerText = 'Jump'
  282. return
  283. }
  284. control.btn.innerText = 'Repeat'
  285. const time = parseTime(input)
  286. if (time === -1) {
  287. control.btn.disabled = true
  288. return
  289. }
  290. control.btn.disabled = false
  291. toValue = time
  292. updateURL()
  293. })
  294.  
  295. // Button export
  296. control.btnExport.addEventListener('click', (evt) => {
  297. evt.preventDefault()
  298. const videoId = getVideoId(window.location)
  299. alert(`youtube-dl -f 0 "https://www.bilibili.com/video/${videoId}" \\
  300. -x --audio-format mp3 --audio-quality 192k \\
  301. --postprocessor-args "-ss ${fromValue} -to ${toValue} -af loudnorm=I=-16:TP=-2:LRA=11" \\
  302. -o "output-%(id)s-${fromValue}-${toValue}.%(ext)s"`)
  303. })
  304.  
  305. function setInitialDuration(dur) {
  306. control.inputTo.placeholder = `to ${dur.toFixed(2)}`
  307. const input = control.inputTo.value
  308. if (input !== '') return
  309. toValue = dur
  310. }
  311.  
  312. if (videoElement.duration) {
  313. setInitialDuration(videoElement.duration)
  314. } else {
  315. videoElement.addEventListener('loadedmetadata', () => {
  316. setInitialDuration(videoElement.duration)
  317. })
  318. }
  319. }
  320.  
  321. main()