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

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

  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.8.0
  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. const hms = str.split(':')
  60. let time = 0
  61. for (const i of hms) {
  62. time *= 60
  63. time += Number(i)
  64. if (isNaN(time)) return -1
  65. }
  66. return time
  67. }
  68.  
  69. function generateControl() {
  70. const app = document.createElement('div')
  71. const cutBar = document.createElement('div')
  72. const inputFrom = document.createElement('input')
  73. inputFrom.placeholder = 'from 0'
  74. const inputTo = document.createElement('input')
  75. inputTo.placeholder = 'to ...'
  76. const currentTime = document.createElement('span')
  77. const btn = document.createElement('button')
  78. const btnStop = document.createElement('button')
  79. const btnExport = document.createElement('button')
  80. const btnCut = document.createElement('button')
  81. applyStyle(app, {
  82. display: 'flex',
  83. alignItems: 'center',
  84. justifyContent: 'space-between',
  85. maxWidth: '700px',
  86. marginTop: '15px',
  87. marginLeft: 'auto',
  88. marginRight: 'auto',
  89. })
  90. applyStyle(cutBar, {
  91. display: 'flex',
  92. flexWrap: 'wrap',
  93. marginTop: '1vh',
  94. })
  95. applyStyle(currentTime, {
  96. fontSize: '1.3rem',
  97. minWidth: '8.1rem',
  98. textAlign: 'center',
  99. color: 'var(--yt-spec-text-primary)',
  100. })
  101. const inputCommonStyle = {
  102. width: '120px',
  103. }
  104. applyStyle(inputFrom, inputCommonStyle)
  105. applyStyle(inputTo, inputCommonStyle)
  106. btn.innerText = 'Jump'
  107. btnStop.innerText = 'Stop'
  108. btnExport.innerText = 'Export'
  109. btnCut.innerText = 'Cut'
  110. app.appendChild(inputFrom)
  111. app.appendChild(inputTo)
  112. app.appendChild(currentTime)
  113. app.appendChild(btn)
  114. app.appendChild(btnStop)
  115. app.appendChild(btnExport)
  116. app.appendChild(btnCut)
  117. return {
  118. app,
  119. cutBar,
  120. inputFrom,
  121. inputTo,
  122. currentTime,
  123. btn,
  124. btnStop,
  125. btnExport,
  126. btnCut,
  127. }
  128. }
  129.  
  130. function generateFullControl(videoElement) {
  131. const control = generateControl()
  132.  
  133. // States
  134. let fromValue = 0
  135. let toValue = 0
  136.  
  137. // Initial state update attempt
  138. const urlTime = window.location.hash.match(
  139. /#pvp([0-9]+\.?[0-9]?)-([0-9]+\.?[0-9]?)/
  140. )
  141. if (urlTime !== null) {
  142. console.log('Attempting to recover time from URL...')
  143. control.inputFrom.value = fromValue = Number(urlTime[1]) || 0
  144. control.inputTo.value = toValue = Number(urlTime[2]) || 0
  145. }
  146.  
  147. // Current playback time
  148. function updateCurrentTime() {
  149. control.currentTime.innerText = Number(videoElement.currentTime).toFixed(2)
  150. requestAnimationFrame(updateCurrentTime)
  151. }
  152. requestAnimationFrame(updateCurrentTime)
  153.  
  154. // Repeat playback
  155. function onTimeUpdate() {
  156. if (videoElement.currentTime >= Number(toValue)) {
  157. videoElement.currentTime = Number(fromValue)
  158. }
  159. }
  160.  
  161. control.btn.addEventListener('click', (evt) => {
  162. evt.preventDefault()
  163. videoElement.pause()
  164. videoElement.currentTime = fromValue
  165. if (fromValue < toValue) {
  166. videoElement.play()
  167. videoElement.addEventListener('timeupdate', onTimeUpdate)
  168. } else {
  169. videoElement.removeEventListener('timeupdate', onTimeUpdate)
  170. }
  171. })
  172.  
  173. control.btnStop.addEventListener('click', (evt) => {
  174. evt.preventDefault()
  175. videoElement.removeEventListener('timeupdate', onTimeUpdate)
  176. videoElement.pause()
  177. })
  178.  
  179. control.btnCut.addEventListener('click', () => {
  180. const nowTime = Number(videoElement.currentTime).toFixed(2)
  181. const btn = createCutButton(nowTime, videoElement)
  182. control.cutBar.append(btn)
  183. })
  184.  
  185. control.btnCut.addEventListener('contextmenu', (evt) => {
  186. evt.preventDefault()
  187. if (!control.cutBar) return
  188. const timings = collectCutTiming(control.cutBar)
  189. const newTimings = prompt(
  190. 'This is your current cut list. Change it to import cut from others.',
  191. JSON.stringify(timings)
  192. )
  193. if (newTimings === null) return
  194. const parsedNewTimings = (() => {
  195. try {
  196. return JSON.parse(newTimings)
  197. } catch {
  198. console.warn('Failed to parse the new cut list.')
  199. return []
  200. }
  201. })()
  202. if (JSON.stringify(timings) === JSON.stringify(parsedNewTimings)) {
  203. console.log('No changes on the cut list.')
  204. return
  205. }
  206. control.cutBar.innerHTML = ''
  207. for (const i of parsedNewTimings) {
  208. const btn = createCutButton(i, videoElement)
  209. control.cutBar.append(btn)
  210. }
  211. })
  212.  
  213. // Start/end time setting
  214. function updateURL() {
  215. history.pushState(null, null, `#pvp${fromValue}-${toValue}`)
  216. }
  217. control.inputFrom.addEventListener('change', () => {
  218. const input = control.inputFrom.value
  219. if (input === '') {
  220. fromValue = 0
  221. control.inputFrom.placeholder = 'from 0'
  222. return
  223. }
  224. const time = parseTime(input)
  225. if (time === -1) {
  226. control.btn.disabled = true
  227. return
  228. }
  229. control.btn.disabled = false
  230. fromValue = time
  231. updateURL()
  232. })
  233. control.inputTo.addEventListener('change', () => {
  234. const input = control.inputTo.value
  235. if (input === '') {
  236. toValue = videoElement.duration || 0
  237. control.inputTo.placeholder = `to ${toValue.toFixed(2)}`
  238. control.btn.innerText = 'Jump'
  239. return
  240. }
  241. control.btn.innerText = 'Repeat'
  242. const time = parseTime(input)
  243. if (time === -1) {
  244. control.btn.disabled = true
  245. return
  246. }
  247. control.btn.disabled = false
  248. toValue = time
  249. updateURL()
  250. })
  251.  
  252. // Button export
  253. control.btnExport.addEventListener('click', (evt) => {
  254. evt.preventDefault()
  255. const videoId = getVideoId(window.location)
  256. alert(`youtube-dl -f bestaudio "https://www.youtube.com/watch?v=${videoId}" \\
  257. -x --audio-format mp3 --audio-quality 192k \\
  258. --postprocessor-args "-ss ${fromValue} -to ${toValue} -af loudnorm=I=-16:TP=-2:LRA=11" \\
  259. -o "output-%(id)s-${fromValue}-${toValue}.%(ext)s"`)
  260. })
  261.  
  262. function setInitialDuration(dur) {
  263. control.inputTo.placeholder = `to ${dur.toFixed(2)}`
  264. const input = control.inputTo.value
  265. if (input !== '') return
  266. toValue = dur
  267. }
  268.  
  269. if (videoElement.duration) {
  270. setInitialDuration(videoElement.duration)
  271. } else {
  272. videoElement.addEventListener('loadedmetadata', () => {
  273. setInitialDuration(videoElement.duration)
  274. })
  275. }
  276.  
  277. return control
  278. }
  279.  
  280. function keepControl() {
  281. if (!String(window.location).includes('/watch?')) return
  282. if (!control || control.app.offsetHeight === 0) {
  283. console.log('New video playback page found. Trying to insert the widget...')
  284. const video = document.querySelector('video')
  285. const anchorNewYouTubeUI = document.querySelector('#below')
  286. const anchorOldYouTubeUI = document.querySelector(
  287. 'ytd-video-primary-info-renderer'
  288. )
  289. const anchor =
  290. // if the new UI is visible
  291. anchorNewYouTubeUI && anchorNewYouTubeUI.offsetParent
  292. ? // use the new UI
  293. anchorNewYouTubeUI
  294. : // or, if the old UI is visible
  295. anchorOldYouTubeUI && anchorOldYouTubeUI.offsetParent
  296. ? // use the old UI
  297. anchorOldYouTubeUI
  298. : // Not found, stop
  299. null
  300. if (!video || !anchor) {
  301. console.log('Anchor not found. Retrying...')
  302. return
  303. }
  304. console.log('Video and anchor found. Releasing the widget...')
  305. control = generateFullControl(video)
  306.  
  307. // insert the widget
  308. anchor.parentElement.insertBefore(control.app, anchor)
  309. anchor.parentElement.insertBefore(control.cutBar, anchor)
  310.  
  311. console.log('The widget is up.')
  312. }
  313. }
  314.  
  315. keepControl()
  316. setInterval(keepControl, 1000)