Greasy Fork is available in English.

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

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

// ==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.6
// @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',
    marginTop: '3px',
  })
  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) {
  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: '700px',
    marginTop: '15px',
    marginLeft: 'auto',
    marginRight: 'auto',
  })
  applyStyle(cutBar, {
    display: 'flex',
    flexWrap: 'wrap',
    marginTop: '1vh',
  })
  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 = '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,
  }
}

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)
    )
    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.inputTo.placeholder = `to ${toValue.toFixed(2)}`
      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 bestaudio "https://www.youtube.com/watch?v=${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)
    })
  }

  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)