Serverless
基于Serverless
Serverless,一种架构思想,该思想贯穿了整个"弹幕视频网站"的设计和开发过程。
而且可以说发挥到了极致。
点播、直播、弹幕、聊天、监控等都实现了,但我没有租用或购买任何一台服务器。
(一个极度"投机取巧"的架构,取了一个高大上的名字,基于"Serverless"。)
那么,我花了多少钱呢? 居然是零耶! \begin{aligned}
& \textbf{那么,我花了多少钱呢?} \\
& \textbf{居然是零耶!}
\end{aligned}
那么,我花了多少钱呢? 居然是零耶!
什么是Serverless
关于Serverless的资料有很多,而且那些资料一般还会再引出各种概念,什么函数即服务,平台即服务等,让人看得云里雾里。
其实很简单,"Server"是服务器,"Serverless"就是说尽量少的依赖服务器(特指自有服务器)。
为什么要尽量少的依赖自有服务器呢?
为了降低服务器的压力。
如何做到Serverless
在早期的一些网络游戏中,部分"战斗过程"是在客户端完成的,在"战斗结束"后,客户端再把"战斗结果"上传给服务器,这就是"Serverless"思想的体现。但是这么做,存在一个问题,容易导致外挂猖獗。既然"战斗过程"是在客户端完成的,那么整个"战斗过程"对于客户端来说,就是可被操纵的。这也是"Serverless"的缺点之一,不够安全。
做到"Serverless"有两种方法,除了上述例子的通过客户端实现 ,还有一种方法通过第三方实现 。
视频
视频的存储与传输
视频的存储与传输,采取的就是通过第三方实现 。
传统的视频网站的技术方案,不论是自建服务器,利用云服务器,还是利用OSS,都必须承担存储费用和带宽费用,而且开销不会太小。
作为个人网站,尤其是不以盈利为目的的个人网站,这种传统方案显然是不适合的。
所以考虑非传统的方案,大致思路是:
利用Github存储
利用jsDelivr加快访问
在具体实现方案上,分为两步:
切片
部署
我们依次讨论。
切片
视频文件通常较大。
Github不支持100MB以上的文件
jsDelivr不支持20MB以上的文件
对视频进行切片,有利于视频的加载效率
基于上述原因,对视频文件进行切片。
视频文件标准化
切片之前,首先需要对视频文件进行标准化,否则在切片的时候会报错。
可以利用格式工厂等软件对视频文件进行标准化。
视频流的编码转为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》 。
部署
部署的操作很简单:
PUSH到Github
发布release
PUSH到Github
将上述生成的切片文件(切片后的ts文件)
、playlist.m3u8
以及弹幕json文件
,PUSH到Github的仓库。
建议为每一个视频,新建一个Github仓库。因为,Github对仓库的大小有建议,不超过1G。
另外,强烈建议新建一个Github账号!
因为该方案有风险!可能导致被Ban!所以!强烈建议新建一个Github账号!
发布release
通过Github网站发布release。
我们可以通过PotPlayer或其他播放器,试一下上述的视频的m3u8视频是否可以正常播放。
可以通过浏览器,试一下弹幕是否可以正常打开。
关于Git
,可以参考《未分类【计算机】:版本控制系统Git入门》 。
点播
点播和直播所借助的播放器为都是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浏览器中部分弹幕会闪
我在dplayer
的Github
仓库中,搜索了很多关于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 = "" .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 <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 ( ) { 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 }); 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(); }); dp.on('playing' , function ( ) { if (isSeeked == false ){ isSeeked = true ; var urlSeconds = getUrlSeconds(); dp.seek(urlSeconds[1 ]); } }); dp.on('progress' , function ( ) { if ((dp.video.duration - dp.video.currentTime) < 45 ){ var tsId = parseInt (getUrlSeconds()[0 ]) 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 ); 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 requestsrnt = [] for i in range(1 , 82 ): 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 jsonimport timeimport hashlibimport requestsurl = '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.connect({ token: token }).then(user => { }).catch(error => { log('告警:直播连接失败' , '详情:' + JSON .stringify(error)) }); } 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({ count: 50 }).then(function ( ) { joinedChatRoom = true ; }); } function canJoinChatRoom ( ) { getConStatusIndex = 0 ; return canJoinChatRoomRec() } function canJoinChatRoomRec ( ) { 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 ), 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++; 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 ; 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 ], 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: 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) {} } }, status(event) { setConStatus(event.status) 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.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; var curUserCount = document .getElementById("onLineCount" ).value 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: 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: 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 timeimport hashlibimport requestsimport datetimeimport zipfileimport osimport jsonimport smtplibfrom email.mime.text import MIMETextfrom email.mime.multipart import MIMEMultipartfrom email.utils import formataddrurl = 'https://api-cn.ronghub.com/message/history.json' batchDate = datetime.datetime.now().strftime('%Y%m%d' ) 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 = 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() headers = {"App-Key" : app_key, "Nonce" : nonce, "Timestamp" : time_stamp, "Signature" : signature, "Content-Type" : "application/x-www-form-urlencoded" } data = {'date' : date} response = requests.post(url, headers=headers, data=data) j = json.loads(response.content.decode()) 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() 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' ])) if d['type' ] == 'right' : dplayer_danmu.append(0 ) elif d['type' ] == 'top' : dplayer_danmu.append(1 ) else : dplayer_danmu.append(2 ) 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' for i in range(1 , 83 ): get_danmu_url = danmu_url + str(i).rjust(2 , '0' ) + danmu_suf danmu_json = json.loads(requests.get(get_danmu_url).content.decode('utf-8' )) if ('date' in danmu_json) and (danmu_json['date' ] == batchDate): 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 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 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的前端开发入门:3.DOM和BOM》 讨论localtion
的时候,我们讨论过。
一个url中,包含了网络协议、服务器的主机名、端口号、资源名称字符串、参数以及锚点。
1 protocol://host[:port]/path/[?query]#fragment
在CORS
中,协议
、域名(主机名)
和端口
三者,组成一个origin
。
三者都一样,才是同源;有一者不一样,都不是同源;http
和https
也不是同源,属于不同的协议。
为什么浏览器要阻止跨域
为什么要浏览器要阻止跨域呢?
比如说,你现在登录了支付宝官网alipay.com
,那么在浏览器的中就有了alipay.com
的相关cookie。然后你又访问我的网站kakawanyifan.com
,我在这个网站中,写了一段js脚本,去请求alipay
的接口,这时候浏览器会带上alipay.com
的cookie,那么,我就有可能,转账到我自己的支付宝账户。
出于安全考虑,浏览器要阻止跨域。
怎么解决跨域
通过上文的例子,阻止跨域很合理啊,为了安全。
但是,一个网站可能由多个"服务"组成。比如,在看"武林外传"的时候,视频其实来自https://cdn.jsdelivr.net
。
等等!为什么看"武林外传"又没问题?
这就涉及到跨域的解决方法了。
跨域有三种解决方法:
被访问的一方主动允许跨域
比如,https://cdn.jsdelivr.net
就允许跨域。
通过合适的服务器,代理访问。
利用浏览器中允许跨域的标签。
怎么解决弹幕跨域
阿里官方给出了方案,用来解决跨域,其实就是被访问的一方主动允许跨域 。
监控
视频监控
正如上问所述,该视频方案存在风险,因为涉嫌滥用Github和jsDelivr,可能会被Ban。
而且据说被Ban之前,都不会有邮件通知。
所以我们需要对风险进行监控。
阿里云风险监控
可以采用"阿里云-云监控-站点监控"。
根据网页提示,配置监控规则即可。
如果被Ban,HTTP的状态代码是403。所以,监控规则的高级设置采取默认即可。
HTTP状态码
这里顺便解释一下HTTP的状态码。
1xx
,信息性状态码。例如:100
、101
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('弹幕速度:较快' )) 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('弹幕速度:默认' )) 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('弹幕速度:较慢' )) 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('弹幕速度:最慢' )) 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('弹幕速度:最快' )) } })) }
解释说明:
每次点击,进行如下操作:
判断当前的速度(根据图标判断)
修改图标内容
调节速度。(速度+ 1 +1 + 1 ,如果已经速度已经是5 5 5 ,点击之后速度回到1 1 1 )
记录弹幕速度。
调节速度的代码如下
示例代码:
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]
,0 0 0 。
记录弹幕速度
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;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 ) { var banKeyword = layero.find(".layui-layer-input" ).val().replace(/,/g , "," ).trim(); saveBanKeyWrod(banKeyword); setBanKeyword(banKeyword); layer.close(index); } }); })) }
解释说明:
因为可能会存在多个全角的逗号,
,所以利用了正则表达式/,/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 ( ) { touchStartAt = Math .round(event.changedTouches[0 ].clientX); })),this .player.template.videoWrap.addEventListener("touchmove" , (function ( ) { if (!e.player.options.live && canTouchSeek){ 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 ( ) { 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 ; 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 dp.on('progress' , function ( ) { if ((dp.video.duration - dp.video.currentTime) < 45 ){ var tsId = parseInt (getUrlSeconds()[0 ]) 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,其要求加载更多内容才会开始视频播放,所以加载两个。
如果加载的文件,在后面又没有用,浏览器的控制台会打印内容。
输入弹幕中暂停视频
具体如下:
输入弹幕的框弹出的时候,暂停视频。
弹幕发送,并且内容不为空;或者框关闭的时候;继续播放。
直播页面,发送弹幕后,如果不是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 ) { if (13 === (e || window .event).keyCode){ if (!t.options.live && t.template.commentInput.value.replace(/^\s+|\s+$/g , "" )){ t.video.play() } a.send() if (navigator.platform != "iPhone" && navigator.platform != "iPad" && t.options.live){ 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() if (navigator.platform != "iPhone" && navigator.platform != "iPad" && t.options.live){ 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 ) { 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");
最后一个话题,随机昵称的来源。