精确控制视频播放进度 (YouTube)

精确控制视频播放进度/生成剪辑脚本的工具栏

目前为 2020-08-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Precise video playback (YouTube)
  3. // @name:zh-CN 精确控制视频播放进度 (YouTube)
  4. // @description A toolbar to set precise video play time and generate clip script
  5. // @description:zh-CN 精确控制视频播放进度/生成剪辑脚本的工具栏
  6. // @homepage https://github.com/suisei-cn/pvp
  7. // @namespace moe.suisei.pvp.youtube
  8. // @match https://www.youtube.com/*
  9. // @match https://youtube.com/*
  10. // @grant none
  11. // @version 0.7.2
  12. // @author Outvi V
  13. // ==/UserScript==
  14.  
  15. 'use strict'
  16.  
  17. let control
  18.  
  19. console.log('Precise Video Playback is up. Watching for video players...')
  20.  
  21. function collectCutTiming (cutBar) {
  22. return [...cutBar.querySelectorAll('div > button:nth-child(1)')].map((x) =>
  23. Number(x.innerText)
  24. )
  25. }
  26.  
  27. function createCutButton (time, videoElement) {
  28. const btnJump = document.createElement('button')
  29. const btnRemove = document.createElement('button')
  30. const btnContainer = document.createElement('div')
  31. btnJump.innerText = time
  32. btnRemove.innerText = 'x'
  33. btnJump.addEventListener('click', () => {
  34. videoElement.currentTime = time
  35. })
  36. btnRemove.addEventListener('click', () => {
  37. btnContainer.style.display = 'none'
  38. })
  39. applyStyle(btnContainer, {
  40. marginRight: '0.5vw',
  41. flexShrink: '0',
  42. marginTop: '3px'
  43. })
  44. btnContainer.append(btnJump, btnRemove)
  45. return btnContainer
  46. }
  47.  
  48. function getVideoId (url) {
  49. return String(url).match(/v=([^&#]+)/)[1]
  50. }
  51.  
  52. function applyStyle (elem, styles) {
  53. for (const [key, value] of Object.entries(styles)) {
  54. elem.style[key] = value
  55. }
  56. }
  57.  
  58. function parseTime (str) {
  59. if (!isNaN(Number(str))) return Number(str)
  60. const time = str.match(/([0-9]?)?:([0-9]+)(\.([0-9]+))?/)
  61. if (time === null) return -1
  62. const ret =
  63. Number(time[1] || 0) * 60 + Number(time[2]) + Number(time[4] || 0) * 0.1
  64. if (isNaN(ret)) return -1
  65. return ret
  66. }
  67.  
  68. function generateControl () {
  69. const app = document.createElement('div')
  70. const cutBar = document.createElement('div')
  71. const inputFrom = document.createElement('input')
  72. inputFrom.placeholder = 'from time'
  73. const inputTo = document.createElement('input')
  74. inputTo.placeholder = 'to time'
  75. const currentTime = document.createElement('span')
  76. const btn = document.createElement('button')
  77. const btnStop = document.createElement('button')
  78. const btnExport = document.createElement('button')
  79. const btnCut = document.createElement('button')
  80. applyStyle(app, {
  81. display: 'flex',
  82. alignItems: 'center',
  83. justifyContent: 'space-between',
  84. maxWidth: '700px',
  85. marginTop: '15px',
  86. marginLeft: 'auto',
  87. marginRight: 'auto'
  88. })
  89. applyStyle(cutBar, {
  90. display: 'flex',
  91. flexWrap: 'wrap',
  92. marginTop: '1vh'
  93. })
  94. applyStyle(currentTime, {
  95. fontSize: '1.3rem',
  96. minWidth: '8.1rem',
  97. textAlign: 'center',
  98. color: 'var(--yt-spec-text-primary)'
  99. })
  100. const inputCommonStyle = {
  101. width: '120px'
  102. }
  103. applyStyle(inputFrom, inputCommonStyle)
  104. applyStyle(inputTo, inputCommonStyle)
  105. btn.innerText = 'Repeat play'
  106. btnStop.innerText = 'Stop'
  107. btnExport.innerText = 'Export'
  108. btnCut.innerText = 'Cut'
  109. app.appendChild(inputFrom)
  110. app.appendChild(inputTo)
  111. app.appendChild(currentTime)
  112. app.appendChild(btn)
  113. app.appendChild(btnStop)
  114. app.appendChild(btnExport)
  115. app.appendChild(btnCut)
  116. return {
  117. app,
  118. cutBar,
  119. inputFrom,
  120. inputTo,
  121. currentTime,
  122. btn,
  123. btnStop,
  124. btnExport,
  125. btnCut
  126. }
  127. }
  128.  
  129. function generateFullControl (videoElement) {
  130. const control = generateControl()
  131.  
  132. // States
  133. let fromValue = 0
  134. let toValue = 0
  135.  
  136. // Initial state update attempt
  137. const urlTime = window.location.hash.match(
  138. /#pvp([0-9]+\.?[0-9]?)-([0-9]+\.?[0-9]?)/
  139. )
  140. if (urlTime !== null) {
  141. console.log('Attempting to recover time from URL...')
  142. control.inputFrom.value = fromValue = Number(urlTime[1]) || 0
  143. control.inputTo.value = toValue = Number(urlTime[2]) || 0
  144. }
  145.  
  146. // Current playback time
  147. function updateCurrentTime () {
  148. control.currentTime.innerText = Number(videoElement.currentTime).toFixed(2)
  149. requestAnimationFrame(updateCurrentTime)
  150. }
  151. requestAnimationFrame(updateCurrentTime)
  152.  
  153. // Repeat playback
  154. function onTimeUpdate () {
  155. if (videoElement.currentTime >= Number(toValue)) {
  156. videoElement.currentTime = Number(fromValue)
  157. }
  158. }
  159.  
  160. control.btn.addEventListener('click', (evt) => {
  161. evt.preventDefault()
  162. videoElement.pause()
  163. videoElement.currentTime = fromValue
  164. if (fromValue < toValue) {
  165. videoElement.play()
  166. videoElement.addEventListener('timeupdate', onTimeUpdate)
  167. } else {
  168. videoElement.removeEventListener('timeupdate', onTimeUpdate)
  169. }
  170. })
  171.  
  172. control.btnStop.addEventListener('click', (evt) => {
  173. evt.preventDefault()
  174. videoElement.removeEventListener('timeupdate', onTimeUpdate)
  175. videoElement.pause()
  176. })
  177.  
  178. control.btnCut.addEventListener('click', () => {
  179. const nowTime = Number(videoElement.currentTime).toFixed(2)
  180. const btn = createCutButton(nowTime, videoElement)
  181. control.cutBar.append(btn)
  182. })
  183.  
  184. control.btnCut.addEventListener('contextmenu', (evt) => {
  185. evt.preventDefault()
  186. if (!control.cutBar) return
  187. const timings = collectCutTiming(control.cutBar)
  188. const newTimings = prompt(
  189. 'This is your current cut list. Change it to import cut from others.',
  190. JSON.stringify(timings)
  191. )
  192. if (newTimings === null) return
  193. const parsedNewTimings = (() => {
  194. try {
  195. return JSON.parse(newTimings)
  196. } catch {
  197. console.warn('Failed to parse the new cut list.')
  198. return []
  199. }
  200. })()
  201. if (JSON.stringify(timings) === JSON.stringify(parsedNewTimings)) {
  202. console.log('No changes on the cut list.')
  203. return
  204. }
  205. control.cutBar.innerHTML = ''
  206. for (const i of parsedNewTimings) {
  207. const btn = createCutButton(i, videoElement)
  208. control.cutBar.append(btn)
  209. }
  210. })
  211.  
  212. // Start/end time setting
  213. function updateURL () {
  214. history.pushState(null, null, `#pvp${fromValue}-${toValue}`)
  215. }
  216. control.inputFrom.addEventListener('change', () => {
  217. const input = control.inputFrom.value
  218. if (input === '') {
  219. fromValue = 0
  220. control.inputFrom.placeholder = 'from 0'
  221. return
  222. }
  223. const time = parseTime(input)
  224. if (time === -1) {
  225. control.btn.disabled = true
  226. return
  227. }
  228. control.btn.disabled = false
  229. fromValue = time
  230. updateURL()
  231. })
  232. control.inputTo.addEventListener('change', () => {
  233. const input = control.inputTo.value
  234. if (input === '') {
  235. toValue = videoElement.duration || 0
  236. control.inputTo.placeholder = `to ${toValue.toFixed(2)}`
  237. return
  238. }
  239. const time = parseTime(input)
  240. if (time === -1) {
  241. control.btn.disabled = true
  242. return
  243. }
  244. control.btn.disabled = false
  245. toValue = time
  246. updateURL()
  247. })
  248.  
  249. // Button export
  250. control.btnExport.addEventListener('click', (evt) => {
  251. evt.preventDefault()
  252. const videoId = getVideoId(window.location)
  253. alert(`ffmpeg -i $(youtube-dl -f bestaudio -g "https://www.youtube.com/watch?v=${videoId}") \
  254. -ss ${fromValue} \
  255. -to ${toValue} \
  256. -acodec libmp3lame \
  257. -ab 192k \
  258. -af loudnorm=I=-16:TP=-2:LRA=11 \
  259. -vn \
  260. output-${videoId}-${fromValue}-${toValue}.mp3`)
  261. })
  262.  
  263. return control
  264. }
  265.  
  266. function keepControl () {
  267. if (!String(window.location).includes('/watch?')) return
  268. if (!control || control.app.offsetHeight === 0) {
  269. console.log(
  270. 'New video playback page found. Trying to insert the widget...'
  271. )
  272. const video = document.querySelector('video')
  273. const anchor = document.querySelector('ytd-video-primary-info-renderer')
  274. if (!video || !anchor) return
  275. console.log('Video and anchor found. Releasing the widget...')
  276. control = generateFullControl(video)
  277. anchor.parentElement.insertBefore(control.app, anchor)
  278. anchor.parentElement.insertBefore(control.cutBar, anchor)
  279. console.log('The widget is up.')
  280. }
  281. }
  282.  
  283. keepControl()
  284. setInterval(keepControl, 1000)