效果

png

实现思路

  1. 页面结构->canvas 覆盖在视频上面,弹幕输入框,按钮,弹幕颜色选择器
  2. 初始化数据
  3. 初始化 canvas
  4. 整理数据参数,循环数据根据参数判定是否绘画弹幕
  5. 监听进度条进行弹幕绘制复原
  6. 点击发送收集信息 push 到源数据

实现源码

html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视频弹幕功能</title>
<link rel="stylesheet" href="./Static/index.min.css" />
</head>
<body>
<div class="container">
<div class="video-wrapper">
<canvas class="canvas"></canvas>
<video src="./Static/李世民.mp4" class="video" controls></video>
</div>
<div class="tool-box">
<input type="text" class="text-input" />
<button class="btn">发送弹幕</button>
<input type="color" class="color-input" value="#e66465" />
</div>
</div>
<script async src="./index.js" type="module"></script>
</body>
</html>

css

body {
margin: 0;
padding: 0;
}

input,
button {
outline: none;
border: none;
vertical-align: middle;
box-sizing: border-box;
}

.container {
width: 1000px;
margin: 0 auto;
}

.container .video-wrapper {
position: relative;
}

.container .video-wrapper .video {
width: 100%;
}

.container .video-wrapper .canvas {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}

.tool-box {
height: 40px;
margin-top: 15px;
}

.tool-box .text-input,
.tool-box .btn,
.tool-box .color-input {
height: 100%;
margin-right: 15px;
border-radius: 5px;
}

.tool-box .text-input {
width: 300px;
border: 1px solid #ccc;
}

.tool-box .btn {
color: #333;
background-color: #fff;
cursor: pointer;
}

.tool-box .btn:hover {
color: #fff;
background-color: #00a2ff;
}

.tool-box .color-input {
cursor: pointer;
}

index.js

import { barrageData } from "./utils/data.js";
import { VideoBarrage } from "./utils/videoClass.js";
const videoWrapper = document.querySelector(".video");
const canvasWrapper = document.querySelector(".canvas");
const textInput = document.querySelector(".text-input");
const colorInput = document.querySelector(".color-input");
const button = document.querySelector(".btn");
let video;
const init = () => {
video = new VideoBarrage(videoWrapper, canvasWrapper, { barrageData });
eventBind();
};
//事件绑定
const eventBind = () => {
videoWrapper.addEventListener("play", handleVideoPlay, false);
videoWrapper.addEventListener("pause", handleVideoPause, false);
videoWrapper.addEventListener("seeked", handleVideoSeeked, false);
button.addEventListener("click", handleButtonClick, false);
};

/**
* 视频开始播放事件函数
* @param {Event} e
*/
const handleVideoPlay = (e) => {
video.paused = false;
video.render();
};
/**
* 视频暂停播放事件函数
*/
const handleVideoPause = () => (video.paused = true);

/**
* 操作进度条 ->进度条回退保证弹幕再次绘制
*/
const handleVideoSeeked = () => video.reset();

/**
* 点击发送按钮收集当前数据push给源数据
*/
const handleButtonClick = () => {
if (textInput.value.trim().length === 0) {
return;
}
const content = textInput.value;
const color = colorInput.value;
const runTime = videoWrapper.currentTime;
//添加数据
video.addData({ content, color, runTime });
textInput.value = "";
};

init();

videoClass.js

import { BoundaryProcessing } from "./utils.js";
import { BollData } from "./BollData.js";
export class VideoBarrage {
/** 视频dom @type {HTMLVideoElement} */
#videoWap;
/** 源数据 @type {Array} */
#data;
/** canvas dom @type {HTMLCanvasElement} */
canvasWap;
/** 标识视频的暂停 @type {boolean} */
paused;
constructor(video, canvas, options) {
//边界处理
if (!BoundaryProcessing(video, canvas, options)) return;
this.#videoWap = video;
this.canvasWap = canvas;
this.#data = options.barrageData;
this.#initCanvas(options);
}
#initCanvas(options) {
//初始化宽高
this.canvasWap.width = this.#videoWap.offsetWidth;
this.canvasWap.height = this.#videoWap.offsetHeight;
this.ctx = this.canvasWap.getContext("2d");
//标记视频的状态
this.paused = true;
//默认值
Object.assign(this, options, {
speed: 2,
runTime: 2,
color: "#ffffff",
});
//二次包装弹幕数据
this.barrageBoolData = this.createData();
this.render();
}
/**
* 格式化包装数据
*/
createData() {
return this.#data.map((item) => new BollData(item, this));
}
render() {
//清除画布
this.clearRect();
//开始绘画
this.drawData();
//视频播放才会一直画弹幕
if (!this.paused) {
requestAnimationFrame(this.render.bind(this));
}
}
drawData() {
//获取视频当前的播放时间
let currTime = this.#videoWap.currentTime;
this.barrageBoolData.map((item) => {
//只处理一次弹幕运动与视频播放到指定时间的弹幕
if (!item.stop && currTime >= item.runTime) {
//只需要初始化一次数据
if (!item.isInit) {
item.init();
item.isInit = true;
}
//弹幕开始运动
item.textX -= item.speed;
item.draw();
//弹幕运动到视频最左侧并消失在视频上
if (item.textX <= item.width * -1) {
item.stop = true;
}
}
});
}
/**
* 清除canvas画布
*/
clearRect() {
this.ctx.clearRect(0, 0, this.canvasWap.width, this.canvasWap.height);
}
/**
* 重置进度条
*/
reset() {
this.clearRect();
//视频当前时间
let currentTime = this.#videoWap.currentTime;
this.barrageBoolData.map((item) => {
//首次绘制
item.stop = false;
if (currentTime <= item.runTime) {
item.isInit = false;
} else {
item.stop = true;
}
});
}
addData(data) {
this.barrageBoolData.push(new BollData(data, this));
}
}

BollData.js

import { getTextWidth, getTextPosition } from "./utils.js";

export class BollData {
/** @type {CanvasRenderingContext2D} */
#ctx;
constructor(item, ctx) {
this.content = item.content;
this.runTime = item.runTime;
this.data = item;
this._ctx = ctx;
this.#ctx = ctx.ctx;
this.init();
}
init() {
this.isInit = false;
this.stop = false;
this.color = this.data.color ?? this._ctx.color;
this.speed = this.data.speed ?? this._ctx.speed;
this.fontSize = 40;
this.width = getTextWidth(this.content, this.fontSize);
this.textX = getTextPosition(this._ctx.canvasWap, this.fontSize)[0];
this.textY = getTextPosition(this._ctx.canvasWap, this.fontSize)[1];
}
draw() {
this.#ctx.font = `${this.fontSize}px Arial`;
this.#ctx.fillStyle = this.color;
this.#ctx.fillText(this.content, this.textX, this.textY);
}
}