avatar


关于弹幕视频网站的实现方案

Serverless

基于Serverless

Serverless,一种架构思想,该思想贯穿了整个"弹幕视频网站"的设计和开发过程。
而且可以说发挥到了极致。
点播、直播、弹幕、聊天、监控等都实现了,但我没有去租用或购买任何一台服务器。
(一个极度"投机取巧"的架构,取了一个高大上的名字,基于"Serverless"。)

那么,我花了多少钱呢?居然是零耶!\begin{aligned} & \textbf{那么,我花了多少钱呢?} \\ & \textbf{居然是零耶!} \end{aligned}

居然是零耶

什么是Serverless

关于Serverless的资料有很多,而且那些资料一般还会再引出各种概念,什么函数即服务,平台即服务等,让人看得云里雾里。
其实很简单,"Server"是服务器,"Serverless"就是说尽量少的依赖服务器(特指自有服务器)。

为什么要尽量少的依赖自有服务器呢?
为了降低服务器的压力。

如何做到Serverless

在早期的一些网络游戏中,部分"战斗过程"是在客户端完成的,在"战斗结束"后,客户端再把"战斗结果"上传给服务器,这就是"Serverless"思想的体现。但是这么做,存在一个问题,容易导致外挂猖獗。既然"战斗过程"是在客户端完成的,那么整个"战斗过程"对于客户端来说,就是可被操纵的。这也是"Serverless"的缺点之一,不够安全。

做到"Serverless"有两种方法,除了上述例子的通过客户端实现,还有一种方法通过第三方实现

视频

视频的存储与传输

视频的存储与传输,采取的就是通过第三方实现

传统的视频网站的技术方案,不论是自建服务器,利用云服务器,还是利用OSS,都必须承担存储费用和带宽费用,而且开销不会太小。
作为个人网站,尤其是不以盈利为目的的个人网站,这种传统方案显然是不适合的。

所以考虑非传统的方案,大致思路是:

  1. 利用Github存储
  2. 利用jsDelivr加快访问

在具体实现方案上,分为两步:

  1. 切片
  2. 部署

我们依次讨论。

切片

视频文件通常较大。

  1. Github不支持100MB以上的文件
  2. jsDelivr不支持20MB以上的文件
  3. 对视频进行切片,有利于视频的加载效率

基于上述原因,对视频文件进行切片。

视频文件标准化

切片之前,首先需要对视频文件进行标准化,否则在切片的时候会报错。
可以利用格式工厂等软件对视频文件进行标准化。

  • 视频流的编码转为AVC(H264)
  • 音频流的编码转为AAC

视频文件切片

通过网站 https://ffmpeg.org/ ,下载ffmpeg,在下载的压缩包找到ffmpeg.exe

将视频转换为ts格式。

1
.\ffmpeg -y -i 【文件名】.mp4 -vcodec copy -acodec copy -vbsf h264_mp4toannexb 【文件名】.ts

对ts文件进行切片。

1
.\ffmpeg -i 【文件名】.ts -c copy -map 0 -f segment -segment_list playlist.m3u8 -segment_time 30 【文件名】%03d.ts

建议每个视频切片的长度为30秒左右。
为什么呢?
因为每次建立HTTP连接,传输数据,都有一个复杂的过程。

  • 如果ts片段太短的话,比如5秒,意味着下一个ts片段需要在5秒内传输过来,而每一次请求都耗费了大量建立连接的时间。
    (我试验过ts片段为5秒的情况,卡顿的情况比30秒严重太多)
  • 如果ts片段太长的话,比如超过60秒,意味着文件太大,可能加载会非常缓慢。

那么为什么是30秒?不是28秒,29秒呢?30秒只是一个经验数字。

也有资料建议:第一个ts片段长度为3秒;第二个ts片段长度为5秒;第三个ts片段长度为10秒左右;第四个ts片段以上长度为30秒左右;最长ts片段不超过35秒。这种建议是为了提高视频初次载入速度,无论哪种建议,其原因都和网络请求的过程有关。

关于ffmpeg,可以参考《未分类【计算机】:视频音频处理工具FFmpeg》

部署

部署的操作很简单:

  1. PUSH到Github
  2. 发布release

PUSH到Github

将上述生成的切片文件(切片后的ts文件)playlist.m3u8以及弹幕json文件,PUSH到Github的仓库。
建议为每一个视频,新建一个Github仓库。因为,Github对仓库的大小有建议,不超过1G。

另外,强烈建议新建一个Github账号!
因为该方案有风险!可能导致被Ban!所以!强烈建议新建一个Github账号!

发布release

通过Github网站发布release。

我们可以通过PotPlayer或其他播放器,试一下上述的视频的m3u8视频是否可以正常播放。
可以通过浏览器,试一下弹幕是否可以正常打开。

关于Git,可以参考《未分类【计算机】:版本控制系统Git入门》

点播

hexo-tag-mmedia

点播和直播所借助的播放器为都是dplayer
点播是在2021-09-28上线的,当时直接利用了hexo-tag-mmedia插件。
直播因为涉及到众多的事件监听操作等,不能直接利用hexo-tag-mmedia插件,采取了自行引入dplayer的方案。
我们先讨论点播。

hexo-tag-mmedia的官网文档:https://www.u2sb.com/OpenSw/hexo-tag-mmedia/

安装hexo-tag-mmedia

1
npm install hexo-tag-mmedia@1 --save

_config.yml新增配置

1
2
3
4
5
6
7
8
9
10
11
mmedia:
dplayer:
# js: https://cdn.jsdelivr.net/npm/dplayer@1/dist/DPlayer.min.js
js: /js/50/player.js
hls_js: https://cdn.jsdelivr.net/npm/hls.js/dist/hls.min.js
dash_js: https://cdn.jsdelivr.net/npm/dashjs/dist/dash.all.min.js
shaka_dash_js: https://cdn.jsdelivr.net/npm/shaka-player/dist/shaka-player.compiled.js
flv_js: https://cdn.jsdelivr.net/npm/flv.js/dist/flv.min.js
webtorrent_js: https://cdn.jsdelivr.net/npm/webtorrent/webtorrent.min.js
default:
contents:
  • 注意js: /js/50/player.js,因为修改了播放器源码。

引用视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{% mmedias "dplayer" "m3u8:" "hls:https://cdn.jsdelivr.net/npm/hls.js/dist/hls.min.js" %}
{
video:
{
url: '【视频地址】',
type: 'hls'
},
danmaku:
{
api: '【弹幕地址】',
unlimited: true
},
screenshot: true,
lang: 'zh-cn'
}
{% endmmedias %}
  • lang: 'zh-cn':设置播放器的语言为中文,默认是跟随浏览器的语言。
  • screenshot: true:开启截图功能。
    实际修改了screenshot的图标以及相关的事件函数代码,改为了调整代码速度的功能。所以只是让该图标可见。

修复在Safari浏览器中部分弹幕会闪

我在dplayerGithub仓库中,搜索了很多关于Safari浏览器中弹幕会闪的issue,原作者确实没有回应修复方法,但是有很多其他开发者的提出的修复方法,并贴出很多代码。
再多次实验后,我发现,起关键核心作用的是这段代码。

1
2
3
4
5
<style type="text/css">
.dplayer-danmaku-move {
text-shadow: 1px 1px 1px black !important;
}
</style>

的确,设置text-shadow,可以解决Safari浏览器中部分弹幕会闪的BUG,这或许和Safari浏览器的BUG有关。

获取弹幕的相关源码修改

dplayer要求弹幕必须是由相关的API接口提供的,传入id参数。这样的话,就需要租用或购买服务器了。
但,实际上,只要是能提供json,就行。
考虑对dplayer的源码进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return t = e, (a = [{
key: "load",
value: function () {
var e, t = this;
// e = this.options.api.maximum ? "".concat(this.options.api.address, "v3/?id=").concat(this.options.api.id, "&max=").concat(this.options.api.maximum) : "".concat(this.options.api.address, "v3/?id=").concat(this.options.api.id);
e = "".concat(this.options.api.address);
var a = (this.options.api.addition || []).slice(0);
a.push(e), this.events && this.events.trigger("danmaku_load_start", a), this._readAllEndpoints(a, (function (e) {
t.dan = [].concat.apply([], e).sort((function (e, t) {
return e.time - t.time
})), window.requestAnimationFrame((function () {
t.frame()
})), t.options.callback(), t.events && t.events.trigger("danmaku_load_end")
}))
}
}
  • 注释为被修改的地方。

关于发送弹幕的代码也有修改,在下文讨论聊天的时候会进行讨论。

直播

伪直播

传统的视频直播方案很有多,其中一种是利用ffmpeg进行推流,这样的话,就需要租用或购买服务器了。
这里,考虑利用dplayer的切换视频源和快进的功能,做到大家都在看同一个内容的伪直播方案,在客户端实现"直播"。
因为需要利用dplayer的很多事件监听功能,所以不能直接利用插件,需要自行引入dplayer。

1
2
3
<!-- 播放器JS -->
<script src="/js/50/player.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js/dist/hls.min.js"></script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// 每一集的长度,单位:秒
const vl = [2723, 2601, 2601, 2619, 2587, 2612, 2579, 2611, 2599, 2513, 2604, 2568, 2597, 2602, 2577, 2589, 2601, 2582, 2568, 2603, 2598, 2614, 2590, 2580, 2610, 2591, 2594, 2599, 2592, 2582, 2603, 2595, 2599, 2601, 2622, 2582, 2627, 2600, 2599, 2604, 2567, 2597, 2596, 2608, 2607, 2604, 2591, 2602, 2591, 2612, 2622, 2606, 2612, 2613, 2597, 2588, 2590, 2589, 2597, 2599, 2609, 2596, 2595, 2601, 2596, 2597, 2602, 2611, 2615, 2601, 2604, 2611, 2606, 2599, 2606, 2604, 2599, 2602, 2612, 2700, 2787]
// 所有视频的长度
const vlSum = 210859

// 视频
const videoUrl = 'https://cdn.jsdelivr.net/gh/90500/【ID】/playlist.m3u8'
// 弹幕
const danmakuApi = 'https://cdn.jsdelivr.net/gh/90500/【ID】/danmu.json'

// 东八区
const shanghaiOffset = -480

var dp;
var isSeeked = false;

// 获取当前应该直播的内容
function getUrlSeconds() {
// month的值域为0~11,0代表1月,11表代表12月;
// hrs的值域在0~23之间。从午夜到次日凌晨1点间hrs=0,从中午到下午1点间hrs=12;
var r1 = '01'
var r2 = 0;
// 分钟转换成秒
// 为了兼容国际用户
var offSet = - (shanghaiOffset - new Date().getTimezoneOffset()) * 60
start = new Date(2021, 9, 18, 05, 58, 00).getTime();
now = new Date().getTime();
diff = parseInt((now - start) / 1000) + offSet
diff = diff % vlSum
for (let index = 0; index < vl.length; index++) {
if (diff - vl[index] < 0) {
r1 = (Array(2).join(0) + (index + 1)).slice(-2)
r2 = diff
break;
}
diff = diff - vl[index];
}
return [r1, r2]
}

window.onload = function () {

var xhr = new XMLHttpRequest();
xhr.addEventListener("readystatechange", function() {
if((this.readyState === 4) && (JSON.parse(this.response).country_code == 'CN')){
layer.msg('检测到在中国大陆访问<br/>因为视频服务都在境外<br/>所以视频可能无法播放<br/>如果能挂梯子会很流畅',{title:false,closeBtn:0,skin:'layui-layer-molv',btn:['确认'],area:'185px', time:0})

}
});
xhr.open("GET", "https://api.ip.sb/geoip");
xhr.send();


var urlSeconds = getUrlSeconds();
var urlStr = videoUrl.replace('【ID】', urlSeconds[0])
var apiStr = danmakuApi.replace('【ID】', urlSeconds[0])
// 实例化
dp = new DPlayer({
container: document.getElementById('dplayer'),
lang: 'zh-cn',
video: {
url: urlStr,
type: 'hls'
},
danmaku: {
api: apiStr,
unlimited: true
},
autoplay: true,
screenshot: true,
live: true
});
// 监听play
dp.on('play', function () {

var urlSeconds = getUrlSeconds();
// 如果不是要播放的那一集
if (urlStr.indexOf('/' + urlSeconds[0] + '/') == -1) {
urlStr = videoUrl.replace('【ID】', urlSeconds[0])
apiStr = danmakuApi.replace('【ID】', urlSeconds[0])
dp.switchVideo({
url: urlStr,
type: 'hls'
}, {
api: apiStr,
unlimited: true
});
}
dp.seek(urlSeconds[1]);

if (canJoinChatRoom()){
joinChatRoom()
}
});
// 监听结束事件
dp.on('ended', function () {
dp.play();
});
// 监听playing事件
dp.on('playing', function () {
if(isSeeked == false){
isSeeked = true;
var urlSeconds = getUrlSeconds();
dp.seek(urlSeconds[1]);
}
});
// 监听process事件
dp.on('progress', function () {
// 小于45秒,说明是最后一个了。
if((dp.video.duration - dp.video.currentTime) < 45){
// 字符串转数字,01会转成1
var tsId = parseInt(getUrlSeconds()[0])
// 如果tsId 是81,下一次播放是1,否则都是+1
tsId = (tsId == 81) ? 1 : tsId + 1
// 预加载两个
preload.loadFile(preTs.replace(/【ID】/g, (Array(2).join(0) + tsId).slice(-2)) + '000' + sufTs);
preload.loadFile(preTs.replace(/【ID】/g, (Array(2).join(0) + tsId).slice(-2)) + '001' + sufTs);
}
});

// 如果可以加入
if(canJoinChatRoom()){
joinChatRoom();
}

// 五秒钟更新一次在线人数
setInterval(onLineCount,5000);

// iPhone去除一个全屏
if (navigator.platform == "iPhone" || navigator.platform == "iPad") {
document.getElementById("fullScreen").setAttribute("style","display:none");
} else{
document.getElementById("fullBrowser").setAttribute("style","display:none");
}
document.getElementById("to_comment").setAttribute("style","display:none");
document.getElementById("go-up").setAttribute("style","display:none");
}

部分代码解读:

getUrlSeconds()中,获取的实际上是客户端的时间。对于不同时区的客户端的时间是不一样的,所以在getUrlSeconds()中,有一段代码是将时间统一为东八区。

在进行播放器的实例化时,根据getUrlSeconds(),获取当前的视频和弹幕地址,作为参数进行实例化。

实例化的部分参数解释:

  • container: document.getElementById('dplayer'):播放器容器元素
  • autoplay: true:自动播放,在PC和Mac上,可以自动播放。但是真正的作用是在移动设备上,每次切换视频源的时候,不需要手动再点击一次播放键。
  • live: true:标记直播,在聊天等地方有应用。
    仅此而已,真正控制当前视频是否是直播的是m3u8文件,关于该部分可以参考《未分类【计算机】:视频音频处理工具FFmpeg》

监听play事件,获取当前的视频和弹幕,并seek到指定的时间。

监听ended事件,当前视频结束的时候,立即执行play。因此,会再次触发play事件的监听,然后又获取当前的视频和弹幕,并seek到指定的时间。

监听playing事件,因为在有些情况下,进入页面后,会直接开始播放,不会触发play事件,那么也不会seek到指定的时间。
因此在playing事件中,再次进行seek。为了防止多次seek,设置了一个全局的变量isSeeked

监听process事件,在即将播放下一集的时候,预加载部分ts文件。
关于该部分,会在下文的"更多功能"部分做更详细的讨论。

在seek前后,不需要对弹幕进行隐藏等操作。
即,dp.danmaku.clear()(清除所有弹幕)、dp.danmaku.hide()(隐藏弹幕)以及dp.danmaku.show()(显示弹幕)都不需要。

我们可以看看播放器的源码。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
key: "seek",
value: function () {
this.clear();
for (var e = 0; e < this.dan.length; e++) {
if (this.dan[e].time >= this.options.time()) {
this.danIndex = e;
break
}
this.danIndex = this.dan.length
}
}

关于该播放器的更多内容,可以参考:https://dplayer.diygod.dev/guide.html

获取视频时长

在上述代码中,还有两个变量。

  • vl:一个数组,每一集的视频时长。
  • vlSum:所有视频的时长之和。

《武林外传》一共有八十一回,依靠人工显然是不现实的。所以利用Python,写一个脚本,解析每一集的m3u8文件,获取视频时长。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

rnt = []

for i in range(1, 82):
# 左侧补0,右测调整
url = 'XXX' + str(i).rjust(2, '0') + 'XXX.m3u8'
print(url)
response = requests.get(url)
res = response.content.decode()
rel = res.split('\n')
lineSum = 0.0
for line in rel:
if line.startswith('#EXTINF:'):
timeScape = float(line.replace('#EXTINF:', '').replace(',', ''))
lineSum = lineSum + timeScape
print(lineSum)
rnt.append(round(lineSum))

print(rnt)
print(len(rnt))
print(sum(rnt))

运行结果:

1
2
3
[2723, 2601, 2601, 2619, 2587, 2612, 2579, 2611, 2599, 2513, 2604, 2568, 2597, 2602, 2577, 2589, 2601, 2582, 2568, 2603, 2598, 2614, 2590, 2580, 2610, 2591, 2594, 2599, 2592, 2582, 2603, 2595, 2599, 2601, 2622, 2582, 2627, 2600, 2599, 2604, 2567, 2597, 2596, 2608, 2607, 2604, 2591, 2602, 2591, 2612, 2622, 2606, 2612, 2613, 2597, 2588, 2590, 2589, 2597, 2599, 2609, 2596, 2595, 2601, 2596, 2597, 2602, 2611, 2615, 2601, 2604, 2611, 2606, 2599, 2606, 2604, 2599, 2602, 2612, 2700, 2787]
81
210859

聊天

接下来,就是基于Serverless的聊天,利用了第三方的IM服务商,融云。
为了做到Serverless,我把token保存了在客户端,这显然是不合适的。
(虽然融云可以设置安全域名,只能在安全域名下通过JavaScript SDK进行调用)

关于融云,可以参考文档:
服务端集成:https://doc.rongcloud.cn/imserver/server/v1/overview
客户端集成:https://doc.rongcloud.cn/im/Web/5.X/prepare

获取token

token必须通过"服务端集成",免费账户最多能获取1000个账户,即100个token。
获取token的Python脚本如下:
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import json
import time
import hashlib
import requests

url = 'https://api-cn.ronghub.com/user/getToken.json'
appKey = '【appKey】'
appSecret = '【appSecret】'


token_list = []
for i in range(100):
userId = str(i)
name = str(i)
nonce = str(i)
# 时间戳
timeStamp = str(int(round(time.time() * 1000)))
# 哈希计算
signature = hashlib.sha1((appSecret + nonce + timeStamp).encode('utf-8')).hexdigest()
headers = {"App-Key": appKey, "Nonce": nonce, "Timestamp": timeStamp, "Signature": signature,"Content-Type": "application/x-www-form-urlencoded"}
data = {'userId': userId, "name": name}
response = requests.post(url, headers=headers, data=data)
j = json.loads(response.content.decode("UTF-8"))
token_list.append(j['token'])

print(len(token_list))
print(token_list)

获取token之后,以js文件的形式,将其存储在客户端。

初始化聊天室

引入融云的JS

1
<script src="https://cdn.ronghub.com/RongIMLib-4.4.7.prod.js"></script>

实例化聊天室

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var tkList = []
var appKey = ''
// 为了便于调试
if(location.hostname == 'localhost'){
// 测试环境
tkList = ['【略】']
appKey = '【appKey】'
}else{
// 生产环境
tkList = ['【略】']
appKey = '【appKey】'
}

const im = RongIMLib.init({'appkey': appKey});

// 创建连接
function createCon() {
var token = tkList[Math.floor(Math.random() * 100)]
// 建立 IM 连接
im.connect({
token: token
}).then(user => {
}).catch(error => {
// 写日志,连接失败
log('告警:直播连接失败', '详情:' + JSON.stringify(error))
});
}

// IM
// 群组编号
const chatRoomId = 'live';

const chatRoom = im.ChatRoom.get({
id: chatRoomId
});

解释说明:每次随机利用一个token建立连接。

创建并加入聊天室

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function joinChatRoom(){
chatRoom.join({
// 进入后, 自动拉取 50 条聊天室最新消息
count: 50
}).then(function() {
joinedChatRoom = true;
});
}

function canJoinChatRoom(){
getConStatusIndex = 0;
return canJoinChatRoomRec()
}

function canJoinChatRoomRec(){
// 如果没有加入过,且连接准备好了,且dp准备好了
if((!joinedChatRoom) && (getConStatus() == 0) && dp){
return true
}else{
if(getConStatusIndex > 30){
return false;
}
getConStatusIndex = getConStatusIndex + 1;
setTimeout("canJoinChatRoomRec",1000);
}
}

// 如果可以加入
if(canJoinChatRoom()){
joinChatRoom();
}

解释说明:

  • 调用chatRoom.join时候,如果当前聊天室不存在,会创建并加入聊天室。如果聊天室存在,直接加入。
  • 先判断是否可以加入聊天室。如果没有加入过聊天室、并且已经连接上了融云、并且播放器dp已经实例化好了,则加入聊天室。否则,每隔一秒钟,轮询一次;最多等待30秒。

连接状态

获取连接状态在融云为付费功能,出于成本的考虑,将连接状态记录在本地。

1
2
3
4
5
6
7
8
9
10
11
12
// 记录连接状态
function setConStatus(cvalue) {
localStorage.setItem("conStatus", cvalue)
}

// 获取连接状态
function getConStatus() {
if (null == localStorage.getItem("conStatus")) {
return 9;
}
return localStorage.getItem("conStatus")
}

发送弹幕

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
key: "send",
value: function (e, t) {
// 内容删除
document.getElementById('commentInput').value = "";
if(notLivePage()){
var a = this,
n = {
token: this.options.api.token,
id: this.options.api.id,
author: this.options.api.user,
time: this.options.time(),
text: e.text,
color: e.color,
type: e.type
},
danmu = {
episode: location.pathname.substr(-2),
// token: this.options.api.token,
// id: this.options.api.id,
author: '【非直播页面】',
time: this.options.time(),
text: e.text,
color: '#' + e.color.toString(16),
type: typeMapping[e.type],
cur: cur
};
demandSaveDanmu(danmu)
this.dan.splice(this.danIndex, 0, n)
this.danIndex++;
// notLivePage
this.options.apiBackend.send({
url: null,
data: n,
success: t,
error: t
})
var o = {
text: this.htmlEncode(n.text),
color: n.color,
type: n.type,
border: "1px solid ".concat(this.options.borderColor)
};
this.draw([o]), this.events && this.events.trigger("danmaku_send", n)
}else{
var curD = new Date();
// 东八区
var shanghaiOffset = -480;
// 小时 和东八区相差的小时 加上.0,因为有相差0.5单位时区的
var offSet = - (shanghaiOffset - (new Date()).getTimezoneOffset()) / 60.0;
// 上海时间
curD = new Date(new Date().setHours(new Date().getHours() + offSet ));
var cur = (Array(2).join(0) + (curD.getMonth() + 1)).slice(-2) + '-' + (Array(2).join(0) + curD.getDate()).slice(-2) + ' ' + (Array(2).join(0) + curD.getHours()).slice(-2) + ':' + (Array(2).join(0) + curD.getMinutes()).slice(-2)

var a = this,
n = {
token: this.options.api.token,
id: this.options.api.id,
author: this.options.api.user,
time: this.options.time(),
text: e.text,
color: e.color,
type: e.type
},
danmu = {
episode: getUrlSeconds()[0],
// token: this.options.api.token,
// id: this.options.api.id,
author: getUserName(),
time: this.options.time(),
text: e.text,
color: '#' + e.color.toString(16),
type: typeMapping[e.type],
cur: cur
};
log('弹幕:' + danmu.text,'详情:' + JSON.stringify(danmu))
sendDanmu(danmu)
this.dan.splice(this.danIndex, 0, n)
this.danIndex++;
var o = {
text: this.htmlEncode(n.text),
color: n.color,
type: n.type,
border: "1px solid ".concat(this.options.borderColor)
};
this.draw([o]), this.events && this.events.trigger("danmaku_send", n)
}
}

解释说明:

  • 对于非直播页面的发送弹幕,调用demandSaveDanmu(danmu)
  • 对于直播页面的发送弹幕,调用sendDanmu(danmu)
  • 无论直播或者非直播,都会在播放器窗口绘制弹幕。

sendDanmu(danmu)的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 发送弹幕,以聊天室的方式
function sendDanmu(danmu){
chatRoom.join({
count: -1
}).then(function() {
chatRoom.send({
// 填写开发者定义的 messageType
messageType: RongIMLib.MESSAGE_TYPE.TEXT,
content: danmu
}).then(function(message){
// 发送弹幕成功,绘制弹幕由播放器完成,但是我们要写在窗口
writeDanmu(danmu)
});
});
}

监听新的弹幕和连接

在初始化融云实例的时候,即设置监听。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 添加事件监听
im.watch({
// 监听消息通知
message(event) {
var message = event.message;
// 新接收到的消息内容
if (message.targetId == chatRoomId){
try {
// 离线内容不绘制
if(!message.isOffLineMessage){
// 绘制弹幕
dp.danmaku.draw(message.content);
}
// 显示在聊天窗口
writeDanmu(message.content)
} catch (e) {}
}
},
// 监听 IM 连接状态变化
status(event) {
setConStatus(event.status)
// 如果被T
if (event.status == 6) {
// 写日志,记录被踢,并重连
log('告警:连接池资源紧张', '详情:' + im._token)
createCon();
}
},
});

解释说明:

  • 监听到新的弹幕,调用dp.danmaku.draw(message.content)绘制在播放器,并同时显示在聊天窗口。
  • 但是对于属于离线性质的弹幕,不进行绘制。
  • 如果当前token被用了,会被踢下线。同时通知监控,并立即重连。

弹幕写在聊天窗口

对于自己发送的弹幕和监听到的新弹幕,都要写在聊天窗口。

1
<textarea id="danmuArea" readonly="readonly" style="position: relative;line-height: 20px;background-color: #0C0C0C;color: #C2BE9E;font-family: consolas, Menlo, 'PingFang SC', 'Microsoft YaHei', monospace, Helvetica Neue For Number;font-size: 12px;padding:0px;margin:0px;border:none;resize:none;outline:none" class="danmu-area-height"></textarea>
1
2
3
4
5
6
7
8
// 弹幕写在窗口
function writeDanmu(danmu){
var textareaObj = document.getElementById("danmuArea");
// var text = danmu.cur + ' ' + danmu.author + ':' + danmu.text + '\n'
var text = danmu.author + ':' + danmu.text + '\n'
textareaObj.append(text)
textareaObj.scrollTop = textareaObj.scrollHeight;
}

在线人数

1
<input type="text" id="onLineCount" readonly="readonly" style="position: relative;line-height: 20px;background-color: #0C0C0C;color: #C2BE9E;font-family: consolas, Menlo, 'PingFang SC', 'Microsoft YaHei', monospace, Helvetica Neue For Number;font-size: 12px;padding:0px;margin:0px;border:0px;width:80px;text-align:center"></input>
1
2
// 五秒钟更新一次在线人数
setInterval(onLineCount,5000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function onLineCount(){
chatRoom.getInfo().then(function(result){
var userCount = result.userCount;
// 对于很久动态的聊天室,融云迟迟不更新其在线人数统计,一旦有人说话立即更新
// 所以会导致一个问题,比如当前人数50多人,一旦有人说话,融云立即更新,可能马上就10几人了
// 导致用户体验不好,方案为减缓其更新速度
var curUserCount = document.getElementById("onLineCount").value
// 如果 curUserCount - 3 还是比 userCount 大
if((curUserCount.indexOf('当前在线:') > -1) && ((parseInt(curUserCount.replace('当前在线:','')) - 3) > userCount)){
userCount = parseInt(curUserCount.replace('当前在线:','')) + (Math.floor(Math.random()*4) - 2)
}
document.getElementById("onLineCount").setAttribute('value','当前在线:' + userCount);
}).catch(error => {
if(error['msg'] == 'NOT_IN_CHATROOM'){
joinChatRoom()
}
});;
}

解释说明:为了优化客户体验,的确存在部分修改,但并不存在实质性的造假。

其他保存功能

“保存屏蔽弹幕关键词"和"保存点播页面的弹幕”,这两个实际上并不属于聊天功能,但是利用了融云的聊天,目的是为了利用融云的导出保存功能。

保存屏蔽弹幕关键词

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 保存被屏蔽的弹幕
function saveBanKeyWrod(banKeyword){
if (getConStatus() != 0){
var token = tkList[Math.floor(Math.random() * 100)]
im.connect({
token: token
}).then(user => {
}).catch(error => {
log('告警:点播连接失败', '详情:' + JSON.stringify(error))
});
}
if (getConStatus() == 0){
// 定义聊天室
var chatRoom = im.ChatRoom.get({id: 'ban'});
chatRoom.join({
// 进入后, 不拉取
count: -1
}).then(function() {
// 发送
chatRoom.send({
// 填写开发者定义的 messageType
messageType: RongIMLib.MESSAGE_TYPE.TEXT,
content: {'episode':'banKeyWord','banKeyword': banKeyword}
}).then(function(message){
log('屏蔽:' + banKeyword,'详情:' + banKeyword)
});
});
}
}

保存点播页面的弹幕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 发送,便于导出
function demandSaveDanmu(danmu){
if (getConStatus() != 0){
var token = tkList[Math.floor(Math.random() * 100)]
im.connect({
token: token
}).then(user => {
}).catch(error => {
log('告警:点播连接失败', '详情:' + JSON.stringify(error))
});
}
if(getConStatus() == 0){
// 定义聊天室
var chatRoom = im.ChatRoom.get({id: 'demand'});
chatRoom.join({
// 进入后, 不拉取
count: -1
}).then(function() {
// 发送
chatRoom.send({
// 填写开发者定义的 messageType
messageType: RongIMLib.MESSAGE_TYPE.TEXT,
content: danmu
}).then(function(message){
im.dis
log('弹幕:' + danmu.text,'详情:' + JSON.stringify(danmu))
});
});
}
}

数据更新

对于新的弹幕,会每天凌晨,通过跑批的方式,及时更新。
并将弹幕、关键词等以邮件的形式发送。

跑批脚本

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
import time
import hashlib
import requests
import datetime
import zipfile
import os
import json
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr

# 获取融云日志的URL
url = 'https://api-cn.ronghub.com/message/history.json'

# 跑批日期
# 因为在UTC标准时间18:00跑,所以北京时间的凌晨2点即UTC标准时间18:00的前一天,所以不用处理时间
batchDate = datetime.datetime.now().strftime('%Y%m%d')

# key
AppKey = '【AppKey】'
AppSecret = '【AppSecret】'

# 发件人邮箱账号
sender = '【sender】'
# 发件人邮箱密码
password = '【password】'
# 收件人邮箱账号
receiver = '【receiver】'
file_dir = '【file_dir】'


def get_log(app_key, app_secret):
"""
获取日志
:param app_key: Key
:param app_secret: Secret
:return: 返回日志
"""
# 新弹幕
log_list = []
if not os.path.exists(file_dir):
os.mkdir(file_dir)

for i in range(24):
# 融云的date
date = batchDate + str(i).rjust(2, '0')
# 随机数
nonce = date
# 时间戳
time_stamp = str(int(round(time.time() * 1000)))
# 哈希计算
signature = hashlib.sha1((app_secret + nonce + time_stamp).encode('utf-8')).hexdigest()
# http的header
headers = {"App-Key": app_key, "Nonce": nonce, "Timestamp": time_stamp, "Signature": signature,
"Content-Type": "application/x-www-form-urlencoded"}
# http的data
data = {'date': date}
# 发起请求,获取响应
response = requests.post(url, headers=headers, data=data)
# load成json
j = json.loads(response.content.decode())
# 如果url不为空
if j['url'] != '':
# 下载文件
response = requests.get(j['url'])
with open(file_dir + j['date'] + '.zip', "wb") as f:
f.write(response.content)

# 解压文件
dir_list = os.listdir(file_dir)
for fileName in dir_list:
fz = zipfile.ZipFile(file_dir + fileName, 'r')
for file in fz.namelist():
fz.extract(file, file_dir)
fz.close()
os.remove(file_dir + fileName)

# 读取解压之后的文件
dir_list = os.listdir(file_dir)
for fileName in dir_list:
with open(file_dir + fileName, 'r', encoding='utf-8') as fr:
while True:
line = fr.readline()
# 在文件中,如果遇到一个空白行,readline()并不会返回一个空串
# 因为每一行的末尾还有一个或多个分隔符,所以"空白行"至少会有一个换行符或者系统使用的其他符号。
# 只有当真的读到文件末尾时,才会读到空串""。
if not line:
break
log_list.append(line)
os.remove(file_dir + fileName)

return log_list


def danmu_convert(d):
"""
弹幕转换
:param d: 弹幕
:return: 新弹幕
"""
dplayer_danmu = []
# 时间
dplayer_danmu.append(round(d['time']))
# 类型
# ['right', 'top', 'bottom']
if d['type'] == 'right':
dplayer_danmu.append(0)
elif d['type'] == 'top':
dplayer_danmu.append(1)
else:
dplayer_danmu.append(2)
# 颜色,16进制转10进制
dplayer_danmu.append(int(d['color'].replace('#', ''), 16))
# 用户
dplayer_danmu.append(d['author'])
# 内容
dplayer_danmu.append(d['text'])

return dplayer_danmu


def gen_new_danmu(log_list):
"""
生成新的弹幕
:param log_list: 日志
:return:
"""
new_danmu_count = 0
new_live_count = 0
new_ban_count = 0
to_add_danmu_list = []
for log in log_list:
j = json.loads(log[19:])
# 非直播的才加入
if j['targetId'] == 'demand':
new_danmu_count = new_danmu_count + 1
to_add_danmu_list.append(j)
elif j['targetId'] == 'live':
new_live_count = new_live_count + 1
# 直播弹幕也加入新的弹幕库
new_danmu_count = new_danmu_count + 1
to_add_danmu_list.append(j)
elif j['targetId'] == 'ban':
new_ban_count = new_ban_count + 1

danmu_url = 'https://danmu.kakawanyifan.com/'
danmu_suf = '.json'
# 从1到82,迭代更新
for i in range(1, 83):
# 左侧补0,右测调整
# 获取弹幕的url
get_danmu_url = danmu_url + str(i).rjust(2, '0') + danmu_suf
# 弹幕json
danmu_json = json.loads(requests.get(get_danmu_url).content.decode('utf-8'))
# 防止重跑,重复添加
if ('date' in danmu_json) and (danmu_json['date'] == batchDate):
# dump还是要的,因为要同步
with open(file_dir + str(i).rjust(2, '0') + danmu_suf, "w", encoding='utf-8') as f:
json.dump(danmu_json, f, ensure_ascii=False)
print('keep:' + str(i).rjust(2, '0') + '.json')
continue

# 否则,就需要加进去
for danmu in to_add_danmu_list:
# 如果是当前集的
if danmu['content']['episode'] == str(i).rjust(2, '0'):
danmu_json['data'].append(danmu_convert(danmu['content']))

# 标上日期
danmu_json['date'] = batchDate
# dump
with open(file_dir + str(i).rjust(2, '0') + danmu_suf, "w", encoding='utf-8') as f:
json.dump(danmu_json, f, ensure_ascii=False)

# 更新
print('update:' + str(i).rjust(2, '0') + '.json')
return new_danmu_count, new_live_count, new_ban_count


def send_mail(mail_msg, log_list):
"""
发送邮件
:param mail_msg: 正文
:return: 返回
"""
# 创建一个带附件的实例
message = MIMEMultipart()
# 发件人
message['From'] = formataddr(("关于弹幕视频网站的例子:数据保存", sender))
# 收件人
message['To'] = formataddr(("kaka", receiver))
# 邮件主题
message['Subject'] = batchDate + "-数据日报"
# 邮件正文内容
message.attach(MIMEText(mail_msg + '\n', 'text/plain', 'utf-8'))

with open(batchDate + '.log', 'w', encoding='utf-8') as fw:
fw.writelines(''.join(log_list))

att = MIMEText(open(batchDate + '.log', 'rb').read(), 'base64', 'utf-8')
att["Content-Type"] = 'application/octet-stream'
att["Content-Disposition"] = 'attachment; filename=' + batchDate + '.log'
message.attach(att)

server = smtplib.SMTP_SSL("smtp.qq.com", 465)
server.login(sender, password)
server.sendmail(sender, [receiver], message.as_string())
server.quit()


if __name__ == '__main__':
print(batchDate)

log_list = get_log(AppKey, AppSecret)
new_danmu_count, new_live_count, new_ban_count = gen_new_danmu(log_list)

mail_msg = ''
mail_msg = mail_msg + '新增弹幕:' + str(new_danmu_count) + '\n'
mail_msg = mail_msg + '直播聊天:' + str(new_live_count) + '\n'

print('邮件正文:')
print(mail_msg)

send_mail(mail_msg, log_list)

print('batch finish')

跑批业务

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# This is a basic workflow to help you get started with Actions
name: '50 Danmu Save'

on:
push:
branches:
- master
schedule:
- cron: '0 18 * * ?'

jobs:
wulin-danmu-save:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Setup Node.js
uses: actions/setup-node@master
with:
node-version: 12.22.6
- name: Install requirements
run: pip install -r ./requirements.txt
- name: batch
run: python batch.py
- name: Setup aliyun oss
uses: yizhoumo/setup-ossutil@v1
with:
endpoint: ${{ secrets.ENDPOINT }}
access-key-id: ${{ secrets.ACCESS_KEY_ID }}
access-key-secret: ${{ secrets.ACCESS_KEY_SECRET }}
- name: Sync danmu to aliyun
run: ossutil sync danmu/ oss://danmu-kakawanyifan-com/ --delete -f

题外话,对于requirements.txt,不需要我们人工进行分析分析,可以通过freeze自动生成。

在项目的目录下,执行pip freeze > requirements.txt

用命令生成的这个requirements.txt文件保存在项目根目录下,其中包含了当前项目用到的所有第三方库和对应的版本号。
将此文件上传到服务器上,然后使用以下命令安装同样版本的第三方库,保证第三方库的版本一致。

1
pip install -r requirements

弹幕跨域

弹幕保存在dammu.kakawanyifan.com,而不是kakawanyifan.com
那么这就可能会涉及到跨域问题了。

什么是跨域

跨域,cross-origin-resource-sharing,简称CORS。
我个人观点,“跨域”,这个翻译不好。域,容易让大家误认为是域名,更好的翻译,应该是跨源。

《基于JavaScript的前端开发入门:2.DOM和BOM》讨论localtion的时候,我们讨论过。
一个url中,包含了网络协议、服务器的主机名、端口号、资源名称字符串、参数以及锚点。

1
protocol://host[:port]/path/[?query]#fragment

CORS中,协议域名(主机名)端口三者,组成一个origin
三者都一样,才是同源;有一者不一样,都不是同源;httphttps也不是同源,属于不同的协议。

为什么浏览器要阻止跨域

为什么要浏览器要阻止跨域呢?

比如说,你现在登录了支付宝官网alipay.com,那么在浏览器的中就有了alipay.com的相关cookie。然后你又访问我的网站kakawanyifan.com,我在这个网站中,写了一段js脚本,去请求alipay的接口,这时候浏览器会带上alipay.com的cookie,那么,我就有可能,转账到我自己的支付宝账户。

出于安全考虑,浏览器要阻止跨域。

怎么解决跨域

通过上文的例子,阻止跨域很合理啊,为了安全。
但是,一个网站可能由多个"服务"组成。比如,在看"武林外传"的时候,视频其实来自https://cdn.jsdelivr.net

等等!为什么看"武林外传"又没问题?
这就涉及到跨域的解决方法了。
跨域有三种解决方法:

  1. 被访问的一方主动允许跨域
    比如,https://cdn.jsdelivr.net就允许跨域。
  2. 通过合适的服务器,代理访问。
  3. 利用浏览器中允许跨域的标签。

怎么解决弹幕跨域

阿里官方给出了方案,用来解决跨域,其实就是被访问的一方主动允许跨域

监控

视频监控

正如上问所述,该视频方案存在风险,因为涉嫌滥用Github和jsDelivr,可能会被Ban。
而且据说被Ban之前,都不会有邮件通知。
所以我们需要对风险进行监控。

阿里云风险监控

可以采用"阿里云-云监控-站点监控"。
根据网页提示,配置监控规则即可。

如果被Ban,HTTP的状态代码是403。所以,监控规则的高级设置采取默认即可。

HTTP状态码

这里顺便解释一下HTTP的状态码。

  • 1xx,信息性状态码。例如:100101
  • 2xx,成功状态码。例如:200
  • 3xx,重定向状态码。
    • 301,永久重定向,Location响应首部的值仍为当前URL,因此也被称为隐藏重定向。
    • 302,临时重定向,Location响应首部的值为新的URL,因此也被称为显式重定向。
    • 304,Not Modified,未修改,比如本地缓存的资源文件和服务器上比较时,发现并没有修改,服务器返回一个304状态码,告诉浏览器,你不用请求该资源,直接使用本地的资源即可。
  • 4xx,客户端错误状态码
    • 401,Unauthorized,未授权
    • 403,Forbidden,拒绝访问
    • 404,Not Found,请求的URL资源并不存在。
  • 5xx,服务器端错误状态码
    • 500,Internal Server Error,服务器内部错误
    • 502,Bad Gateway,前面代理服务器联系不到后端的服务器时出现
    • 504,Gateway Timeout,这个是代理能联系到后端的服务器,但是后端的服务器在规定的时间内没有给代理服务器响应

客户端监控

客户端利用的是Server酱,可以和企业微信进行连接,地址为:https://sct.ftqq.com/

1
2
3
4
5
6
7
8
9
10
// 日志服务
function log(title,desp){
var xhr = new XMLHttpRequest();
var url = "【企业微信通知地址】?title=" + title
if (desp){
url = url + "&desp=" + desp.replace(/"/g,'').replace(/#/g,'')
}
xhr.open("POST", url);
xhr.send();
}

更多功能

弹幕速度可调节

原理

1
2
3
.dplayer-danmaku .dplayer-danmaku-right.dplayer-danmaku-move {
animation: danmaku 8s linear;
}

弹幕速度由该CSS控制,时间数字越小,弹幕越快。
修改该CSS,就可以调节弹幕速度。

调节按键

关于弹幕速度的按键,我采取了修改原有的截图按键的方法。
截图按键默认是不开启的,通过配置screenshot: true,开启截图。
截图的图标原本是一个照相机,替换照相机图标的SVG代码即可修改图标,相关图标可以在iconfont,寻找合适的。

为了实现弹幕速度图标动态变化,还需要利用JS进行修改。
图标在一个span标签的内容中,span标签为<span class="dplayer-icon-content">,但是拥有该class的span标签有很多。考虑加上一个id,<span id="danmuSpeed" class="dplayer-icon-content">,以方便筛选。
代码如下:

1
document.getElementById("danmuSpeed").innerHTML = speedSvg_3;

调节方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
key: "initScreenshotButton",
value: function () {
var e = this;
this.player.options.screenshot && this.player.template.camareButton.addEventListener("click", (function () {
switch (document.getElementById("danmuSpeed").innerHTML) {
case speedSvg_1:
document.getElementById("danmuSpeed").innerHTML = speedSvg_2;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_2;
setDanmuSpeed(2);
e.player.notice(e.player.tran('弹幕速度:较快'))
// layer.msg('弹幕速度:较快')
break;
case speedSvg_2:
document.getElementById("danmuSpeed").innerHTML = speedSvg_3;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_3;
setDanmuSpeed(3);
e.player.notice(e.player.tran('弹幕速度:默认'))
// layer.msg('弹幕速度:默认')
break;
case speedSvg_3:
document.getElementById("danmuSpeed").innerHTML = speedSvg_4;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_4;
setDanmuSpeed(4);
e.player.notice(e.player.tran('弹幕速度:较慢'))
// layer.msg('弹幕速度:较慢')
break;
case speedSvg_4:
document.getElementById("danmuSpeed").innerHTML = speedSvg_5;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_5;
setDanmuSpeed(5);
e.player.notice(e.player.tran('弹幕速度:最慢'))
// layer.msg('弹幕速度:最慢')
break;
case speedSvg_5:
document.getElementById("danmuSpeed").innerHTML = speedSvg_1;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_1;
setDanmuSpeed(1);
e.player.notice(e.player.tran('弹幕速度:最快'))
// layer.msg('弹幕速度:最快')
}
}))
}

解释说明:
每次点击,进行如下操作:

  1. 判断当前的速度(根据图标判断)
  2. 修改图标内容
  3. 调节速度。(速度+1+1,如果已经速度已经是55,点击之后速度回到11)
  4. 记录弹幕速度。

调节速度的代码如下
示例代码:

1
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_5;

为了配合该方法,需要在在相关页面新增如下内容

1
2
3
4
5
<style type="text/css">
.dplayer-danmaku .dplayer-danmaku-right.dplayer-danmaku-move {
animation: danmaku 8s linear;
}
</style>

注意!该标签必须在第一个位置,因为getElementsByTagName("style")[0]00

记录弹幕速度

1
2
3
4
5
6
7
8
9
10
function setDanmuSpeed(cvalue){
localStorage.setItem("danmuSpeed",cvalue)
}

function getDanmuSpeed(){
if (null == localStorage.getItem("danmuSpeed")){
return "3";
}
return localStorage.getItem("danmuSpeed");
}

初始化方法

获取首次打开页面的速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 初始化
// 初始图标
var speedSvg = speedSvg_3;
// 初始弹幕速度,不需要,已经在HTML实现了
// document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_3;

// 初始化方法
function initSpeed() {
switch(getDanmuSpeed()){
case "1":
speedSvg = speedSvg_1;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_1;
break;
case "2":
speedSvg = speedSvg_2;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_2;
break;
case "3":
speedSvg = speedSvg_3;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_3;
break;
case "4":
speedSvg = speedSvg_4;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_4;
break;
case "5":
speedSvg = speedSvg_5;
document.getElementById("article-container").getElementsByTagName("style")[0].innerHTML = speedCss_5;
break;
}
}

// 调用初始化方法
initSpeed();

移动设备的注意事项

在移动设备上,默认截图的图标是不可见的。所以需要让其可见。代码如下

1
2
3
.dplayer.dplayer-mobile .dplayer-controller .dplayer-icons .dplayer-camera-icon{
display:inline-block
}

屏蔽不良弹幕

原理

每次需要绘制的弹幕是一个数组,屏蔽不良弹幕的方法为在绘制弹幕之前,对数组进行过滤。

屏蔽按键

屏蔽按键的设置方法为复用AirPlay的按键,图标修改方法与弹幕速度可调节类似,不赘述。

设置关键词

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
key: "initAirplayButton",
value: function () {
var e = this;
this.player.options.screenshot && this.player.template.airplayButton.addEventListener("click", (function () {
layer.prompt({
formType: 2,
value: getBanKeyword(),
title: '输入需要屏蔽的关键词 多个用逗号分隔',
skin:'layui-layer-molv',
yes: function (index, layero) {
// 获取文本框输入的值
// 替换逗号,/g:替换所有 .trim()
var banKeyword = layero.find(".layui-layer-input").val().replace(/,/g, ",").trim();
// 保存到云端
saveBanKeyWrod(banKeyword);
// 保存到本地文件
setBanKeyword(banKeyword);
layer.close(index);
}
});
}))
}

解释说明:

点击之后,执行layer.prompt,弹窗,输入关键字。为了让用户没有输入内容,依旧可以继续执行,没有采取官方的方法。
具体可以参考:https://blog.csdn.net/qq_43413788/article/details/91982902

因为可能会存在多个全角的逗号,所以利用了正则表达式/,/g进行替换。

saveBanKeyWrod,我们在上文已经讨论过了。

屏蔽不良弹幕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (this.dan.length && !this.paused && this.showing) {
var banKeywordArr = getBanKeyword().split(',')
for (var t = this.dan[this.danIndex], a = []; t && this.options.time() > parseFloat(t.time);) a.push(t), t = this.dan[++this.danIndex];
var filterArr = [];
for(let i = 0; i < a.length; i++) {
var isBan = false;
banKeywordArr.find(function(value) {
// 如果包含
if (null != value && "" != value && a[i].text.indexOf(value) != -1){
isBan = true;
}
})
if (isBan == false){
filterArr.push(a[i])
}
}
this.draw(filterArr)
}

保存关键词到本地

1
2
3
4
5
6
7
8
9
10
11
12
// 记录被禁关键词
function setBanKeyword(cvalue){
localStorage.setItem("banKeyword",cvalue)
}

// 获取被禁关键词
function getBanKeyword(){
if(null == localStorage.getItem("banKeyword")){
return "";
}
return localStorage.getItem("banKeyword")
}

快进快退手势

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
key: "initPlayButton",
value: function () {
var e = this;
this.player.template.playButton.addEventListener("click", (function () {
e.player.toggle()
})), this.player.template.mobilePlayButton.addEventListener("click", (function () {
e.player.toggle()
})), r.isMobile ? (this.player.template.videoWrap.addEventListener("touchstart", (function () {
// 起始位置X坐标
touchStartAt = Math.round(event.changedTouches[0].clientX);
})),this.player.template.videoWrap.addEventListener("touchmove", (function () {
if(!e.player.options.live && canTouchSeek){
// 移动期间X坐标
touchMoveAt = Math.round(event.changedTouches[0].clientX);
// 移动至
var moveTo = Math.round(e.player.video.currentTime + (touchMoveAt - touchStartAt)/10)
// 总时长
var duration = Math.round(e.player.video.duration)
// 通知
e.player.notice(e.player.tran(getFormatDuringTime(moveTo) + '/' + getFormatDuringTime(duration)))
}
})), this.player.template.videoWrap.addEventListener("touchend", (function () {
// 结束位置X坐标
touchedAt = Math.round(event.changedTouches[0].clientX);
// 如果相等,说明是点击
if(touchStartAt == touchedAt){
e.toggle();
}else{
// 如果不是直播
if(!e.player.options.live && canTouchSeek){
// 调整视频
var diff = Math.round((touchedAt - touchStartAt)/10);
e.player.seek(e.player.video.currentTime + diff);
}
}
})),this.player.template.controllerMask.addEventListener("click", (function () {
e.toggle()
}))) : (this.player.template.videoWrap.addEventListener("click", (function () {
e.player.toggle()
})), this.player.template.controllerMask.addEventListener("click", (function () {
e.player.toggle()
})))
}

解释说明:

监听touchstart事件,获取起始位置X坐标。

监听touchmove事件,获取滑动期间的X坐标,并转换成快进时长,再结合视频时长以及当前视频位置,在页面显示快进事件。

监听touchend事件,获取结束滑动时刻的X坐标,并对视频进行seek。如果两次坐标相等,说明是点击事件。

对于直播页面,不进行快进快退。所以有一个判断是e.player.options.live,而live属性,正是在直播页面实例化播放器时候,根据live确定的。
对于刚加载,还未播放的视频,也不允许快进快退。该用户体验参考的是哔哩哔哩。
所以还有一个参数是canTouchSeek
该参数由两个play方法,修改全局变量canTouchSeek。

1
2
3
4
5
6
7
8
9
key: "play",
value: function (e) {
canTouchSeek = true;
var t = this;
if (this.paused = !1, this.video.paused && !r.isMobile && this.bezel.switch(X.play), this.template.playButton.innerHTML = X.pause, this.template.mobilePlayButton.innerHTML = X.pause, e || n.a.resolve(this.video.play()).catch((function () {
t.pause()
})).then((function () {})), this.timer.enable("loading"), this.container.classList.remove("dplayer-paused"), this.container.classList.add("dplayer-playing"), this.danmaku && this.danmaku.play(), this.options.mutex)
for (var a = 0; a < Pe.length; a++) this !== Pe[a] && Pe[a].pause()
}
1
2
3
4
5
key: "play",
value: function () {
canTouchSeek = true;
this.paused = !1
}

上述实现代码还利用了一个函数getFormatDuringTime
该函数如下:

1
2
3
4
5
6
7
8
9
10
11
function getFormatDuringTime(during) {
var s = Math.floor(during / 1) % 60;
during = Math.floor(during / 60);
var i = during % 60;
// during = Math.floor(during / 60);
// var h = during % 24;
// during = Math.floor(during / 24);
// var d = during;
// return d + '天' + h + '时' + i + '分' + s + '秒';
return (Array(2).join(0) + i).slice(-2) + ':' + (Array(2).join(0) + s).slice(-2)
}

直播预加载

正如直播方案,在播放完成一个"playlist.m3u8"之后,会切换到下一个"playlist.m3u8",之间会因为加载ts文件的原因,导致不够连贯。
考虑预加载,方案为利用preloadjs的预加载,并监听progress事件,如果是当前的最后一个ts,则自动加载下一个视频的第一个ts。

preloadjs的官网:https://createjs.com/preloadjs

1
<script src="/js/50/preloadjs.min.js"></script>
1
2
3
4
5
6
7
8
9
10
11
12
13
// 监听process事件
dp.on('progress', function () {
// 小于45秒,说明是最后一个了。
if((dp.video.duration - dp.video.currentTime) < 45){
// 字符串转数字,01会转成1
var tsId = parseInt(getUrlSeconds()[0])
// 如果tsId 是81,下一次播放是1,否则都是+1
tsId = (tsId == 81) ? 1 : tsId + 1
// 预加载两个
preload.loadFile(preTs.replace(/【ID】/g, (Array(2).join(0) + tsId).slice(-2)) + '000' + sufTs);
preload.loadFile(preTs.replace(/【ID】/g, (Array(2).join(0) + tsId).slice(-2)) + '001' + sufTs);
}
});

解释说明:
一般加载一个ts文件就够了,但是对于Safari,其要求加载更多内容才会开始视频播放,所以加载两个。
如果加载的文件,在后面又没有用,浏览器的控制台会打印内容。

输入弹幕中暂停视频

具体如下:

  1. 输入弹幕的框弹出的时候,暂停视频。
  2. 弹幕发送,并且内容不为空;或者框关闭的时候;继续播放。
  3. 直播页面,发送弹幕后,如果不是iPhone或iPad,不退出发送弹幕窗口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function e(t) {
var a = this;
! function (e, t) {
if (!(e instanceof t)) throw new TypeError("Cannot call a class as a function")
}(this, e), this.player = t, this.player.template.mask.addEventListener("click", (function () {
// 继续播放
if(!t.options.live){
t.video.play()
}
a.hide()
})), this.player.template.commentButton.addEventListener("click", (function () {
// 如果不是直播,暂停
if (!t.options.live){
t.video.pause()
t.notice(t.tran("弹幕输入中"))
}
a.show()
})), this.player.template.commentSettingButton.addEventListener("click", (function () {
a.toggleSetting()
})), this.player.template.commentColorSettingBox.addEventListener("click", (function () {
if (a.player.template.commentColorSettingBox.querySelector("input:checked+span")) {
var e = a.player.template.commentColorSettingBox.querySelector("input:checked").value;
a.player.template.commentSettingFill.style.fill = e, a.player.template.commentInput.style.color = e, a.player.template.commentSendFill.style.fill = e
}
})), this.player.template.commentInput.addEventListener("click", (function () {
a.hideSetting()
})), this.player.template.commentInput.addEventListener("keydown", (function (e) {
// 13 === (e || window.event).keyCode && a.send()
if (13 === (e || window.event).keyCode){
// 如果有内容,继续播放
if(!t.options.live && t.template.commentInput.value.replace(/^\s+|\s+$/g, "")){
t.video.play()
}
a.send()
// 如果不是iPhone,并且不是iPad,并且是直播
if (navigator.platform != "iPhone" && navigator.platform != "iPad" && t.options.live){
// focus 归位
t.template.commentInput.focus()
}
}
})), this.player.template.commentSendButton.addEventListener("click", (function () {
// 如果有内容,继续播放
if(!t.options.live && t.template.commentInput.value.replace(/^\s+|\s+$/g, "")){
t.video.play()
}
a.send()
// 如果不是iPhone,并且不是iPad,并且是直播
if (navigator.platform != "iPhone" && navigator.platform != "iPad" && t.options.live){
// focus 归位
t.template.commentInput.focus()
}
}))
}

同步时间轴

参考"微光",设计了同步时间轴的功能。

1
2
3
4
5
6
7
8
9
10
key: "initSyncButton",
value: function () {
var e = this;
this.player.template.syncButton.addEventListener("click", (function () {
if (e.player.options.live){
e.player.notice(e.player.tran('同步时间轴'))
e.player.seek(getUrlSeconds()[1])
}
}))
}

对于非直播页面,需要隐去其同步时间轴的按键。

1
document.getElementById("sync").setAttribute("style","display:none");

直播页面自定义昵称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 随机昵称列表
const randomNameList = ['大周', '胖洪', '帅胡', '小姜', '老高', '小毛', '汤姆', '杰瑞', '米老鼠', '唐老鸭', '苏青青']

// 记录昵称
function setUserName(cvalue) {
localStorage.setItem("userName", cvalue)
}

// 获取昵称
function getUserName() {
if (null == localStorage.getItem("userName") || "" == localStorage.getItem("userName")) {
return randomNameList[Math.floor(Math.random() * 11)]
}
return localStorage.getItem("userName")
}

// 获取昵称
function getUserNameInput() {
if (null == localStorage.getItem("userName")) {
return "";
}
return localStorage.getItem("userName")
}

key: "initNameButton",
value: function () {
var e = this;
this.player.template.nameButton.addEventListener("click", (function () {
layer.prompt({
formType: 2,
value: getUserNameInput(),
title: '请输入昵称',
skin:'layui-layer-molv',
area: ['260px', '35px'],
yes: function (index, layero) {
// 获取文本框输入的值
// 替换逗号,/g:替换所有 .trim()
var userName = layero.find(".layui-layer-input").val().replace(/,/g, ",").trim();
setUserName(userName);
layer.close(index);
}
});
}))
}

对于非直播页面,需要隐去自定义昵称的按键。

1
document.getElementById("comment-name").setAttribute("style","display:none");

最后一个话题,随机昵称的来源。
苏青青

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/90599
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

评论区