Saber 酱的抱枕

Fly me to the moon

03/17
2023
软件

增强在浏览器中播放视频的体验

这篇文章已经意义不大了,因为我们在播放本地视频时可以让 Potplayer 使用视频超分辨率 RTX VSR,不需要在浏览器中播放了。

增强在浏览器中播放视频的体验的用户脚本 UserScript

前些时候我写过一篇文章,尝试了 NVIDIA 的视频超分辨率技术

我下载的资源里有不少低分辨率的视频,现在我习惯把它们放在 Chrome 浏览器里播放,以提高观看时的清晰度。(我是 1440p 显示器,所以 1080p 视频对我来说也算低分辨率)

但是在浏览器里观看视频的体验不是很好,因为我不能用键盘控制视频的进度、音量,特别是不能一键切换上一个/下一个视频,使得播放多个视频时非常麻烦。所以我写了个用户脚本(UserScript)来优化体验(代码在文末)。


像这样一个有很多视频的文件夹:

增强在浏览器中播放视频的体验的用户脚本 UserScript

我把其中一个拖到浏览器中播放:

增强在浏览器中播放视频的体验的用户脚本 UserScript

首先第一个问题就是 Chrome 会自动把视频声音拉满,导致我不得不手动降低视频音量。

在观看时也不能用键盘调整进度。

看完了也不能直接切换到下一个视频。虽然有其他方法可以切换视频,但都做不到一键切换(其他方法例如回到资源管理器里把下一个视频拖进来,或者用 Ctrl + O 打开文件,或者直接把文件夹拖到浏览器里当做目录)。

我参照我平时使用 Potplayer 的习惯,为一些键盘快捷键绑定了简单的功能,并且让左手和右手都能单独操作。

左手快捷键:

  • F 全屏播放/退出全屏
  • A D 后退/前进 5 秒钟
  • W S 增加/减少 10% 音量
  • Q E 手动切换到上一个/下一个视频(*)
  • 左侧 ShfitR 切换循环模式:不循环/单个循环/全部循环

右手快捷键:

  • Enter 全屏播放/退出全屏
  • 后退/前进 5 秒钟
  • 增加/减少 10% 音量
  • PageUp PageDown 手动切换到上一个/下一个视频(*)
  • 右侧 Shift 切换循环模式:不循环/单个循环/全部循环

再加上浏览器自带了用空格键来暂停播放/恢复播放的功能,这样基本的观看体验就有保证了。

另外我还设置了让视频在播放时默认使用 30% 音量,自动启用单个循环。


接下来我简单说下怎么用。

首先要有一个脚本管理器,我用的是 Tampermonkey。

然后在浏览器的扩展管理中点击详情,启用“允许访问文件网址”。

增强在浏览器中播放视频的体验的用户脚本 UserScript

增强在浏览器中播放视频的体验的用户脚本 UserScript

这是因为本地文件的网址是 file:/// 开头的,必须启用这个设置才能让 Tampermonkey 的脚本运行在本地文件页面上。

最后在 Tampermonkey 里新建一个脚本,复制文末的代码粘贴进去保存。

增强在浏览器中播放视频的体验的用户脚本 UserScript

接下来打开一个本地视频,点击 Tampermonkey 的扩展图标,看到这个脚本有在运行就 OK 了。

增强在浏览器中播放视频的体验的用户脚本 UserScript


前面已经说了一些快捷键可以控制视频播放,其中切换视频需要额外说明一下。

因为脚本是在网页里运行的,没有权限自动读取你硬盘上的文件,也就不知道相邻的视频是哪个文件。所以当你按下 Q E 或者 PageUp PageDown 之后,脚本会弹出一个对话框让你选择文件夹,你需要选择这个视频所在的文件夹:

增强在浏览器中播放视频的体验的用户脚本 UserScript

(只需要选择一次,之后切换视频时不需要再次选择)

然后允许脚本查看文件:

增强在浏览器中播放视频的体验的用户脚本 UserScript

这样脚本就可以获得文件夹里的文件列表,然后找到上一个/下一个视频的名字,这样才能切换视频。

增强在浏览器中播放视频的体验的用户脚本 UserScript

并且脚本只能获取到文件名,获取不到路径。比如列表里下一个文件实际路径是 C:\download\阡陌菌-ScarletQ-3D-MMD-舞啪-崩坏三-崩坏3rd-布洛妮娅-大力推荐\2021-10-26-鸭鸭H.mp4,但脚本只知道文件名 2021-10-26-鸭鸭H.mp4,那就只能假定下一个文件和当前播放的文件是相同的路径(也就是在同一个文件夹里)。这就导致了你在选择文件夹时,不应该选择其他文件夹,这样脚本会猜错文件的实际路径(因为脚本始终只会使用第一个播放的视频的路径)。

如果你要看另一个文件夹里的视频,应该把视频拖到浏览器里成为一个单独的标签页。也就是让不同文件夹的视频分开播放。

在切换视频时,脚本会显示新视频在播放列表中的位置,如 2/10。标题栏上也会显示新视频的标题。(不过地址栏里的 URL 不会变成新视频的 URL,这是因为一个技术问题)


如果你已经安装了 Tampermonkey,你可以点击这个链接直接安装脚本:增强播放本地视频的体验-v0.2.user.js

或者手动复制代码:

// ==UserScript==
// @name         Enhance the experience of playing local videos
// @namespace    Play local Video
// @version      0.2
// @description  增强在浏览器中播放本地视频时的便利性。你还可以切换到(当前视频文件夹里的)上一个/下一个视频。
// @author       xuejianxianzun
// @match        file:///*
// @icon         https://saber.love/f/video-icon.png
// @grant        none
// ==/UserScript==

// 增强在浏览器中播放视频的便利性。
// 要使用本脚本,需要注意:
// 1. 需要在扩展管理中打开 Tampermonkey 的“详情”,然后启用“允许访问文件网址”。否则这个脚本不会运行。
// 2. 本脚本切换上一个/下一个视频时,只能在当前视频的文件夹里切换。如果你要播放另一个文件夹里的视频,请新开一个标签页操作。

// 你可以使用以下按键操作视频:
// 空格 暂停/播放视频
// Enter 或 F 全屏/退出全屏
// ← → 或 A D 后退/前进 5 秒钟
// ↑ ↓ 或 W S 增加/减少 10% 音量
// PageUp PageDown 或 Q E 切换到上一个/下一个视频
// Shift 或 R 切换循环模式:不循环/单个循环/全部循环

// ---------- Config ----------

// 默认把视频音量设置为 30%。因为浏览器播放视频时音量是 100%,声音太大了,所以我需要减少音量
const defaultVolume = 0.3

// 每次增加/减少音量时的音量值。默认为 10%。
const stepVolume = 0.1

// 每次前进/后退时的秒数,默认为 5 秒
const stepTime = 5

// 默认显示控制按钮
const showControls = true

// 循环模式
let loopMode = 'single'
const loopModeList = ['no', 'single', 'all']

// 默认不全屏
// 如果设置为 true 会出现意外情况,切换视频时,一个能全屏,一个不能全屏,这样来回变化。
// 而且一开始就全屏的话不会显示播放列表进度
const autoFullScreen = false

// 视频文件的后缀名列表
const videoExts = [
  'mp4',
  'webm',
  'mkv',
  'avi',
  'ts',
  'rmvb',
  'flv',
  'mpg',
  'rm',
  'mpeg',
  'mov',
  'm4v',
  'wmv',
  'asf',
  'divx',
  'vob',
]

// ---------- Code ----------

let video = document.querySelector('video')
let source = video.querySelector('source')
let videoFileNameList = []

if (!video || !source) {
  console.log('Error: video not found!')
} else {
  initVideo()
}

function initVideo () {
  video.volume = defaultVolume
  video.controls = showControls

  if (autoFullScreen && videoFileNameList.length > 0) {
    video.requestFullscreen()
  }

  video.onended = function () {
    switch (loopMode) {
      case 'single':
        video.play()
        showToast('单个循环')
        break
      case 'all':
        next()
        break
      case 'no':
        break
    }
  }
}

window.addEventListener('keydown', (ev) => {
  switch (ev.code) {
    case 'KeyF':
    case 'Enter':
      switchFullScreen()
      break

    case 'ArrowLeft':
    case 'KeyA':
      backward()
      break

    case 'ArrowRight':
    case 'KeyD':
      forward()
      break

    case 'ArrowUp':
    case 'KeyW':
      increaseVolume()
      break

    case 'ArrowDown':
    case 'KeyS':
      reduceVolume()
      break

    case 'PageUp':
    case 'KeyQ':
      prev()
      break

    case 'PageDown':
    case 'KeyE':
      next()
      break

    case 'KeyR':
      switchLoopMode()
      break
  }

  if (ev.shiftKey) {
    switchLoopMode()
  }
})

function switchLoopMode () {
  const index = loopModeList.findIndex(str => str === loopMode)
  loopMode = loopModeList[index === loopModeList.length - 1 ? 0 : index + 1]

  switch (loopMode) {
    case 'single':
      showToast('单个循环')
      break
    case 'all':
      showToast('全部循环')
      break
    case 'no':
      showToast('不循环')
      break
  }
}

function switchFullScreen () {
  if (!document.fullscreenElement) {
    video.requestFullscreen({
      navigationUI: 'hide'
    })
  } else {
    document.exitFullscreen()
  }
}

function forward () {
  video.currentTime = video.currentTime + stepTime

  const number = Math.round(video.currentTime / video.duration * 100)
  showToast(`${number}%`)
}

function backward () {
  video.currentTime = video.currentTime - stepTime

  const number = Math.round(video.currentTime / video.duration * 100)
  showToast(`${number}%`)
}

function increaseVolume () {
  const volume = video.volume + stepVolume
  video.volume = volume > 1 ? 1 : volume

  const number = Math.round(video.volume * 100)
  showToast(`${number}%`)
}

function reduceVolume () {
  const volume = video.volume - stepVolume
  video.volume = volume < 0 ? 0 : volume

  const number = Math.round(video.volume * 100)
  showToast(`${number}%`)
}

async function getVideoList () {
  videoFileNameList = []
  const directoryHandle = await window.showDirectoryPicker({
    id: 'forVideo'
  })

  for await (const [fileName, fileHandle] of directoryHandle.entries()) {
    if (fileHandle.kind === 'file' && videoExts.includes(getFileExt(fileName))) {
      videoFileNameList.push(fileName)
    }
  }
  return videoFileNameList
}

async function findCurrentVideoIndex () {
  if (videoFileNameList.length === 0) {
    console.warn('file list is empty, open directory')
    showToast('播放列表为空,请选择视频文件夹')
    await getVideoList()
  }

  const currentName = getCurrentVideoFileName()
  const index = videoFileNameList.findIndex((name, index) => name === currentName)
  return index

}

async function next () {
  const index = await findCurrentVideoIndex()

  if (index === -1) {
    return
  }

  // 如果现在播放的是最后一个文件,则不处理
  if (index === videoFileNameList.length - 1) {
    showToast(`${index + 1}/${videoFileNameList.length}`)
    return
  }

  playVideo(index + 1)
}

async function prev () {
  const index = await findCurrentVideoIndex()
  if (index === -1) {
    return
  }

  // 如果现在播放的是第一个文件,则不处理
  if (index === 0) {
    showToast(`${index + 1}/${videoFileNameList.length}`)
    return
  }

  playVideo(index - 1)
}

function playVideo (index) {
  const fileName = videoFileNameList[index]
  const URL = decodeURIComponent(source.src)
  const array = URL.split('/')
  array[array.length - 1] = encodeURIComponent(fileName)
  const newURL = array.join('/')

  if (!!document.fullscreenElement) {
    document.exitFullscreen()
  }

  video.remove()
  video = document.createElement('video')
  source = document.createElement('source')
  source.setAttribute('src', newURL)
  source.setAttribute('type', `video/${getFileExt(fileName)}`)
  video.appendChild(source)
  video.load()

  document.body.appendChild(video)
  initVideo()
  video.play()

  document.title = fileName

  console.log(`play list: ${index + 1}/${videoFileNameList.length}`)
  console.log(fileName)

  showToast(`${index + 1}/${videoFileNameList.length}`)

  // history.pushState({}, fileName, newURL)
}

function getFileExt (flieName) {
  const array = flieName.split('.')
  return array[array.length - 1].toLowerCase()
}

function getCurrentVideoFileName () {
  const URL = decodeURIComponent(source.src)
  const array = URL.split('/')
  return array[array.length - 1]
}

// --------- Toast -----------

const css = `.xzToast {
  position: fixed;
  z-index: 2147483647;
  margin: auto;
  font-size: 14px;
  pointer-events: none;
  display: inline-block;
  max-width: 500px;
  color: #fff;
  background-color: #14ad27;
  padding: 8px 10px;
  border-radius: 4px;
}`

const e = document.createElement('style')
e.innerHTML = css
document.body.append(e)

function showToast (msg) {
  const span = document.createElement('span')
  span.textContent = msg

  span.style.opacity = '0' // 先使提示完全透明
  span.classList.add('xzToast')

  // 把提示添加到页面上
  document.body.appendChild(span)

  // 设置 left,使其居中
  // 默认的中间点是窗口的中间
  let centerPoint = window.innerWidth / 2
  const rect = span.getBoundingClientRect()
  let left = centerPoint - rect.width / 2
  span.style.left = left + 'px'

  // 设置 top
  let lastTop = window.innerHeight / 2 - 20

  // 出现动画
  enter(span, 'up', lastTop)

  // 消失动画
  window.setTimeout(() => {
    span.remove()
  }, 1000)
}

// 提示出现的动画
function enter (el, way, lastTop) {
  const startTop = lastTop + 20 // 初始 top 值
  const once = 2
  const total = 20

  let numberOfTimes = 0 // 执行次数

  const frame = function (timestamp) {
    numberOfTimes++

    // 计算总共上移了多少像素
    const move = once * numberOfTimes

    // 计算不透明度
    const opacity = move / total

    if (move <= total && opacity <= 1) {
      if (way === 'up') {
        el.style.top = startTop - move + 'px'
      }

      el.style.opacity = opacity.toString()

      // 请求下一帧
      window.requestAnimationFrame(frame)
    }
  }

  window.requestAnimationFrame(frame)
}

// 一些技术文档:

// KeyboardEvent.code 列表:
// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values

// 元素全屏与退出全屏:
// https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen

// 打开文件夹:
// https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker

// 要改变播放的视频,需要重新生成一个 video(和 source)元素。直接改变 src 是没用的。
// https://stackoverflow.com/questions/5235145/changing-source-on-html5-video-tag

// 使用 history.pushState() 无刷新改变页面标题和 URL
// https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState
// 但是播放本地视频时协议是 file:/// ,这会导致 history.pushState 报错,所以我注释了 history.pushState 代码

增强在浏览器中播放视频的体验