一篇文章理解视频如何实现弹幕发送

604 阅读7分钟

都说自古弹幕出人才,弹幕--B站的一大特色,当我们刷B站的时候不打开弹幕好像就没内味。那么今天我们就来聊聊怎么实现弹幕的发送。


准备工作

视频

这里我们随便准备一个视频即可,我在QQ音乐随便找了个MV

关于类

学过Java的读者应该不会陌生,但在JavaScript中有一点点不同

  1. 类是构造方法的新语法

    • 在 JavaScript 中,class 关键字提供了一种更清晰的方式来定义对象的构造器和原型方法。这一点跟 Java 中的类定义非常相似,可以代码结构更加清晰。
  2. constructor() 用于在 new 时生成实例对象

    • constructor() 方法是在使用 new 关键字创建类的实例时调用的特殊方法。用于初始化实例的属性。相当于 Java 中的构造器。
  3. 类当中定义的方法相当于添加在了构造函数的原型上

    • 在类体中定义的方法实际上被添加到了类的原型 (prototype) 对象上。也就是说所有类的实例将共享这些方法,而不是每个实例都有其自身的副本
    • 这一点与 Java 不同,在 Java 中每个实例都有自己的方法副本。
  4. static 关键字用于将函数声明为静态

    • 使用 static 关键字定义的方法或属性是类级别的,而不是实例级别的。我们可以直接通过类名来调用它们,而不需要先创建类的实例。
    • 相当于 Java 中的静态方法和静态变量。
  5. getset 关键字可以将函数名作为属性来访问

    • 在 JavaScript 中,get 和 set 关键字允许定义 get 和 set 方法,这些方法可以像访问普通属性一样被调用。
    • 这与 Java 中的 getter 和 setter 方法大差不差,但是语法更为简洁。
  6. 类中的私有属性可以定义成 #count = xx

    • 私有字段(使用 # 前缀定义)只能在类内部访问。这有助于封装,确保外部代码不能直接修改这些字段的值。
    • 在 JavaScript 中,这是较新的功能,而在 Java 中所有的成员默认都是私有的,除非明确指定为 public 或 protected

在这里用一个简单的例子来展示这些概念:

class MyClass {
  #privateCount
  constructor(initialCount) {
    this.count = initialCount; // 公有属性
    this.#privateCount = 0;    // 私有属性
  }

  // 类方法(在原型上)
  publicMethod() {
    console.log('Called public method');
  }

  // 静态方法
  static staticMethod() {
    console.log('Called static method');
  }

  // get
  get count() {
    return this.#privateCount;
  }

  // set
  set count(value) {
    if (value >= 0) {
      this.#privateCount = value;
    } else {
      console.log('Value must be non-negative.');
    }
  }
}

// 创建实例
const myInstance = new MyClass(10);

// 访问公有方法
myInstance.publicMethod();

// 访问静态方法
MyClass.staticMethod();

// 使用 get 和 set
console.log(myInstance.count); // 输出私有属性的值
myInstance.count = 20;         // 修改私有属性的值
console.log(myInstance.count); // 输出新的私有属性的值

image.png

HTML部分

  <style>
     body, html {
      margin: 0;
      padding: 0;
      height: 100%; /* 设置 body 和 html 的高度为 100% */
    }

    .wrap {
      display: flex;
      flex-direction: column; /* 主轴为垂直方向 */
      justify-content: center; /* 垂直居中 */
      align-items: center; /* 水平居中 */
      height: 100%; /* 设置 wrap 的高度为 100% */
    }

    .main {
      display: flex; /* 使用 Flexbox */
      align-items: center; /* 垂直居中 */
      justify-content: center; /* 水平居中 */
    }

    #canvas{
      position: absolute;
    }
  </style>
  
<body>
  <div class="wrap">
    <h1>JungKook--Seven</h1>
    <div class="main">
      <!-- <canvas> 元素用于在网页上绘制图形和动画。 -->
      <canvas id="canvas"></canvas>
      <!-- controls 属性表示显示播放控件,如播放/暂停按钮等 -->
      <video src="./mv.mp4" controls width="720" height="480" id="video"></video>
    </div>
    <div class="content">
      <input type="text" id="text">
      <input type="button" id="btn" value="发弹幕">
      <input type="color" id="color">
      <input type="range" id="range" min="20" max="40">
    </div>
  </div>

  <script src="./index.js"></script>
</body>
</html>

input类型汇总

这里我们看到input有很多类型type,这里来整理一下:

下面是一些常见的 <input> 类型及其例子:

  1. type="text"

    • 最常见的用途,文本输入框,用于输入一行文本。
    • 示例: <input type="text" placeholder="Enter your name">

image.png

image.png 2. type="password"

-   密码输入框,输入的字符会被隐藏起来。
-   示例: `<input type="password" placeholder="Enter your password">`

image.png image.png 3. type="submit"

-   提交按钮,用于提交表单数据。
-   示例: `<input type="submit" value="Submit">`

4. type="reset"

-   重置按钮,用于清空表单中的所有字段。
-   示例: `<input type="reset" value="Reset">`

5. type="button"

-   普通按钮,通常与 JavaScript 结合使用。
-   示例: `<input type="button" value="Click Me" onclick="alert('Button clicked!')">`
-  

image.png 6. type="checkbox"

-   复选框,可以同时选择多个选项。
-   示例: `<input type="checkbox" name="option" value="A">选项A`

image.png 7. type="radio"

-   单选按钮,同一组中只能选择一个选项。

-   示例:

```html
<input type="radio" name="gender" value="male"> 男
<input type="radio" name="gender" value="female"> 女
```

image.png 8. type="file"

-   文件上传输入框,用于选择文件。
-   示例: `<input type="file" accept="image/*">`

image.png 9. type="hidden"

-   隐藏字段,用于存储不希望用户看到的信息。
-   示例: `<input type="hidden" name="user_id" value="12345">`

10. type="email"

-   电子邮件地址输入框,自动验证格式是否正确。
-   示例: `<input type="email" placeholder="Enter your email">`

11. type="url"

-   URL 输入框,自动验证格式是否正确。
-   示例: `<input type="url" placeholder="Enter your website URL">`

12. type="tel"

-   电话号码输入框,可以自动识别格式。
-   示例: `<input type="tel" placeholder="Enter your phone number">`

13. type="number"

-   数字输入框,只允许输入数字。
-   示例: `<input type="number" min="1" max="10">`

14. type="range"

-   滑动条,用于设置数值范围。
-   示例: `<input type="range" min="1" max="100">`

动画31.gif 15. type="date"

-   日期选择器,可以选择日期。
-   示例: `<input type="date">`

16. type="month"

-   月份选择器,可以选择月份和年份。
-   示例: `<input type="month">`

image.png 17. type="week"

-   星期选择器,可以选择星期和年份。
-   示例: `<input type="week">`

image.png 18. type="time"

-   时间选择器,可以选择时间。
-   示例: `<input type="time">`

image.png 19. type="datetime-local"

-   日期时间选择器,可以选择日期和时间。
-   示例: `<input type="datetime-local">`

image.png 20. type="search"

-   搜索输入框,类似于文本输入框但带有放大镜图标。
-   示例: `<input type="search" placeholder="Search">`

image.png 21. type="color"

-   颜色选择器,可以选择颜色。
-   示例: `<input type="color">`

image.png

真的有好多类型,html部分过了之后我们看js部分:

JavaScript部分

数据分析

首先我们知道,弹幕有别人发的,有自己发的,弹幕的属性包括value(文字内容)、time(显示时间点)、color(颜色)、speed(移动速度)和 fontSize(字体大小)。

let data = [
  {value: 'JJK~', time: 5, color: 'red', speed: 1, fontSize: 22 },
  {value: '小妞,想你', time: 10, color: '#00a1f5', speed: 1, fontSize: 30 },
  {value: '听不懂思密达', time: 6},
  {value: '田柾国你小子', time: 20, color: '#fff', speed: 1, fontSize: 26 },
]

let canvas = document.getElementById('canvas')
let video = document.getElementById('video')
let $text = document.getElementById('text')
let $btn = document.getElementById('btn')
let $color = document.getElementById('color')
let $range = document.getElementById('range')

这里的data包括了之前已经有的弹幕,然后获取弹幕的html属性

类定义

定义好了属性之后我们现在就需要有一个类(CanvasBarrage)负责管理整个弹幕系统,包括初始化、绘制弹幕等,当我们输入弹幕,就就收到这个弹幕的信息进行初始化(new CanvasBarrage(canvas, video, {data})

CanvasBarrage 类

  • 构造函数:

    • 初始化 canvas 和 video 元素。
    • 设置 canvas 的尺寸与 video 相同。
    • 创建 2d 上下文。
    • 设置默认弹幕选项,并覆盖这些选项以适应提供的数据。
    • 将数据转换为 Barrage 实例数组。
    • 调用 render 方法开始渲染过程。
  • render 方法:

    • 清除画布。
    • 渲染所有弹幕。
    • 使用 requestAnimationFrame 递归调用自身以持续更新画面,除非 isPaused 为 true
  • clear 方法:

    • 清除画布的内容。
  • renderBarrage 方法:

    • 根据视频的当前时间检查哪些弹幕应该显示,并更新它们的位置。
    • 如果弹幕已经离开屏幕,则将其标记为已显示完成。
  • add 方法:

    • 向弹幕数组添加新的弹幕实例。
// 弹幕绘制的准备工作
class CanvasBarrage {
  //函数的形参是可以添加默认值的
  constructor(canvas, video, opts={}) {
    if (!canvas || !video) return //没有视频发个锤子弹幕,返回

    //如果想让这些属性在别的函数里访问到,就得挂在this上,想要过海,就得坐船
    this.video = video
    this.canvas = canvas
    this.canvas.width = video.width
    this.canvas.height = video.height
    // 创建一个2d的画布
    this.ctx = canvas.getContext('2d')
    // 弹幕的默认值
    let defOpts = {
      color: '#e91e63',
      speed: 1,
      opacity: 0.5,
      fontSize: 20,
      data: []
    }
    
    //把opts合并到defOpts上
    Object.assign(this, defOpts, opts)
    
    // 默认暂停状态为true
    this.isPaused = true

    // 得到所有初始化后的弹幕
    this.barrages = this.data.map(item => new Barrage(item, this))
    this.render()
  }
  render() {
    this.clear()
    // 渲染弹幕
    this.renderBarrage()
    // 递归
    if (!this.isPaused) {
      requestAnimationFrame(this.render.bind(this))
    }
  }
  
//画布自清洁
clear() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
  }
  renderBarrage() {
    // 获取到视频的播放时间
    let time = this.video.currentTime
    this.barrages.forEach(barrage => {
      if (time >= barrage.time && !barrage.flag) {
        if (!barrage.isInit) { // 没有初始化
          barrage.init()
          barrage.isInit = true
        }
        barrage.x -= barrage.speed
        barrage.render()
        // 边界
        if (barrage.x < -barrage.width) {
          barrage.flag = true // 弹幕已经出去了
        }
      }
    })
  }
  add(obj) {
    this.barrages.push(new Barrage(obj, this))
  }
}

Barrage 类

Barrage 类代表单个弹幕,负责初始化弹幕的具体属性和绘制弹幕。

  • 构造函数:

    • 初始化弹幕的基本属性。
  • init 方法:

    • 计算弹幕的样式属性,如颜色、速度、不透明度和字体大小。
    • 计算弹幕的宽度。
    • 设置弹幕的初始位置(在屏幕右侧)。
  • render 方法:

    • 在画布上绘制弹幕。
class Barrage{
  constructor(obj, context) {
    this.value = obj.value
    this.time = obj.time
    this.obj = obj
    this.context = context
  }
  init() {
    this.color = this.obj.color || this.context.color
    this.speed = this.obj.speed || this.context.speed
    this.opacity = this.obj.opacity || this.context.opacity
    this.fontSize = this.obj.fontSize || this.context.fontSize

    // 计算每一条弹幕的宽度
    let p = document.createElement('p')
    p.style.fontSize = this.fontSize + 'px'
    p.innerHTML = this.value
    document.body.appendChild(p)
    this.width = p.clientWidth
    document.body.removeChild(p)

    // 弹幕出现的位置
    this.x = this.context.canvas.width
    this.y = this.context.canvas.height * Math.random()
    if (this.y < this.fontSize) {
      this.y = this.fontSize
    } else if (this.y > this.context.canvas.height - this.fontSize) {
      this.y = this.context.canvas.height - this.fontSize
    }

  }
  render() { // 每一条弹幕的渲染
    this.context.ctx.font = `${this.fontSize}px Arial`
    this.context.ctx.fillStyle = this.color
    this.context.ctx.fillText(this.value, this.x, this.y)
  }
}

事件监听器

  • 视频播放时,通过监听 play 事件,设置 isPaused 为 false 并重新开始渲染。
video.addEventListener('play', () => {//视频进行播放就开始渲染
  canvasBarrage.isPaused = false //在播放
  canvasBarrage.render()        //就渲染
})
  • 点击按钮时,获取用户输入的文字、颜色和字体大小,创建一个新的弹幕并添加到列表中。
$btn.addEventListener('click', () => {
  let value = $text.value
  let time = video.currentTime
  let color = $color.value
  let fontSize = $range.value
  let obj = {value, time, color, fontSize}
  canvasBarrage.add(obj)
})

完整js代码:

let data = [
  {value: 'JJK', time: 5, color: 'red', speed: 1, fontSize: 22 },
  {value: '小妞,想你', time: 10, color: '#00a1f5', speed: 1, fontSize: 30 },
  {value: '听不懂思密达', time: 6},
  {value: '田柾国你小子', time: 20, color: '#fff', speed: 1, fontSize: 26 },
]

let canvas = document.getElementById('canvas')
let video = document.getElementById('video')
let $text = document.getElementById('text')
let $btn = document.getElementById('btn')
let $color = document.getElementById('color')
let $range = document.getElementById('range')

// 弹幕绘制的准备工作
class CanvasBarrage {
  //函数的形参是可以添加默认值的
  constructor(canvas, video, opts={}) {
    if (!canvas || !video) return //没有视频发个锤子弹幕,返回

    //如果想让这些属性在别的函数里访问到,就得挂在this上,想要过海,就得坐船
    this.video = video
    this.canvas = canvas
    this.canvas.width = video.width
    this.canvas.height = video.height
    // 创建一个2d的画布
    this.ctx = canvas.getContext('2d')
    // 弹幕的默认值
    let defOpts = {
      color: '#e91e63',
      speed: 1,
      opacity: 0.5,
      fontSize: 20,
      data: []
    }
    //把opts合并到defOpts上
    Object.assign(this, defOpts, opts)
    // 默认暂停状态为true
    this.isPaused = true

    // 得到所有初始化后的弹幕
    this.barrages = this.data.map(item => new Barrage(item, this))
    this.render()
  }
  render() {
    this.clear()
    // 渲染
    this.renderBarrage()
    // 递归
    if (!this.isPaused) {
      requestAnimationFrame(this.render.bind(this))
    }
    
  }
  clear() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
  }
  renderBarrage() {
    // 获取到视频的播放时间
    let time = this.video.currentTime
    this.barrages.forEach(barrage => {
      if (time >= barrage.time && !barrage.flag) {
        if (!barrage.isInit) { // 没有初始化
          barrage.init()
          barrage.isInit = true
        }
        barrage.x -= barrage.speed
        barrage.render()
        // 边界
        if (barrage.x < -barrage.width) {
          barrage.flag = true // 已经出去了
        }
      }
    })
  }
  add(obj) {
    this.barrages.push(new Barrage(obj, this))
  }
}
// 初始化每一条弹幕
class Barrage{
  constructor(obj, context) {
    this.value = obj.value
    this.time = obj.time
    this.obj = obj
    this.context = context
  }
  init() {
    this.color = this.obj.color || this.context.color
    this.speed = this.obj.speed || this.context.speed
    this.opacity = this.obj.opacity || this.context.opacity
    this.fontSize = this.obj.fontSize || this.context.fontSize

    // 计算每一条弹幕的宽度
    let p = document.createElement('p')
    p.style.fontSize = this.fontSize + 'px'
    p.innerHTML = this.value
    document.body.appendChild(p)
    this.width = p.clientWidth
    document.body.removeChild(p)

    // 弹幕出现的位置
    this.x = this.context.canvas.width
    this.y = this.context.canvas.height * Math.random()
    if (this.y < this.fontSize) {
      this.y = this.fontSize
    } else if (this.y > this.context.canvas.height - this.fontSize) {
      this.y = this.context.canvas.height - this.fontSize
    }

  }
  render() { // 每一条弹幕的渲染
    this.context.ctx.font = `${this.fontSize}px Arial`
    this.context.ctx.fillStyle = this.color
    this.context.ctx.fillText(this.value, this.x, this.y)
  }
}



let canvasBarrage = new CanvasBarrage(canvas, video, {data})

video.addEventListener('play', () => {//视频进行播放就开始渲染
  canvasBarrage.isPaused = false //在播放
  canvasBarrage.render()        //就渲染
})



$btn.addEventListener('click', () => {
  let value = $text.value
  let time = video.currentTime
  let color = $color.value
  let fontSize = $range.value
  let obj = {value, time, color, fontSize}
  canvasBarrage.add(obj)
})

看一下最终效果:

动画32.gif

成功实现了弹幕的发送