本文链接已迁移到: /posts/异地多人在线电影院

异地多人在线电影院


引言

最近一段时间和npy异地,经常就会有一些一起看视频的需求,然鹅市面上视频共享的产品要么是因为版权的原因资源不足,要么是因为产品定位没有办法顾及到这种小众市场需求。

作为一个开发,遇到这种问题当然是决定要自己敲一个出来咯。

演示

演示站点:https://movie.vaala.cat/ 不保证可用性,需要的同学请自行搭建。 测试用视频地址:https://media.w3.org/2010/05/sintel/trailer.mp4

电脑和电脑

电脑和手机

产品调研

在准备自己动手之前,首先要分析市面上已有的方案有哪些,需要吸取经验和创意作为自己项目的feature。

在知乎和v2 reddit 以及油管上调研后发现,异地恋在线视频同步这种需求一般有如下几种解决方案:

  • 打个电话喊321,最简单最直接的方案,不过遇到视频卡顿或者想要暂停和分享精彩镜头就有些麻烦了

  • 各种国外产品,例如syncTubetwoseven等,这类产品能够同步的是Netflix或YouTube上的流媒体,在国内几乎没有受众,对我来说也是毫无用处。

  • 各种国内产品,例如BiliBili一起看,微光等产品,这类产品也大都因为版权和产品定位原因,将视频源限制在站内,有些本地的电影或其它小站找到的资源并不能同步。

  • 现有国内开源项目,各位大佬开发的CoplaySyncSofa,前者许久未维护看起来是不可用的状态(作者没异地了似乎就不维护了岂可修),后者是作为浏览器插件运行,难以在手机上实现功能(并且这个作者似乎也是结束异地了岂可修)并且二者平台所限,也只能观看特定平台等内容。

  • 现有国外开源项目SyncPlay ,产品依托于VLC,也只能在电脑上运行,难以移植到手机上。

  • 对现有产品的魔改利用,例如有些同学用TeamViewer,腾讯会议,Zoom等视频会议和远程协作产品来实现一起观看,尝试过后发现因为国内网络环境问题,上行带宽不足,很难达到理想的效果。

  • 额外分一个类:🍎iOS在15中公布了Universal Control和SharePlay,其理念非常符合需求,但现在并不能看出它的游戏,设备也是限定在了苹果生态内,并且也限定了资源源头,也希望库克能把这个产品发扬光大吧。

所以从已有的产品可以看出,我的需求是:

  • 操作简单,无需登陆注册,点击即用,提高npy的使用体验,产品力强。
  • 可以直接载入互联网流媒体资源,资源丰富,载入简单,跨平台,速度快体验良好。
  • 跨平台,无论npy用的是什么设备,不用安装app和插件,可以无感接入,进度条拖到哪就是哪。

技术选型

从上面的需求来看,我最开始想到的是做一个直播推流控制服务器,由资源服务器将视频流通过hls或是rtmp协议传送到浏览器直接播放,跨平台,操作简单,技术成熟。但是实际测试后发现这种产品没有高质量CDN和OSS支撑难以达到好的体验,延迟巨大且不可靠,体验非常不好。

重新思考后我选择使用Web技术栈,做一个PWA,一个链接点开就能用,资源链接粘贴进去就可以开始。并且Web技术栈中有WebRTC和WebSocket这种长链接和数据对等共享技术,非常符合我们的需求。

基于个人情况,我选择的技术栈为

前端:

  • 使用VideoJS加载第三方视频,API丰富,有事件驱动可以实现远端触发本地函数执行。可以加载mp4和webm以及ogg编码的第三方流媒体。
  • 使用WebSocket作为前后端业务数据交换协议,长连接稳定可靠,事件驱动,容易实现多人数据同步(但目前看来有数据冲突,后期准备换用CRDT数据结构)。
  • 使用Vue构建PWA,前后端分离,快速更换服务端降低延迟提升体验。
  • Vuetify:Material Design,动画丰富,用户体验好。

后端:

  • Golang:大道至简。
  • Gin/Socketio:现成的,好用。

可以发现活都用在前端了(毕竟后端是给我自己看,前端是要给npy的,打算做一个无状态服务,轻量,所以就不用数据库了,启动一次用一次。

逻辑设计

对npy来说,想的是随时可以暂停,随时可以拖进度条,而且由于网络环境,她网络经常卡,所以要做自动同步和自动重连,用事件驱动的逻辑我们很好就能设计出原型,我们设计以下事件并定义事件的行为:

  • getUsers:客户端每次发起该请求,服务器便回复allUsers 事件并附上所有用户。
  • allUsers:客户端接收到该事件,需要更新本地显示的用户列表。
  • Null:客户端发起getUsers 请求,如果该房间没有用户,则返回Null ,前端切换到房间创建模式。
  • time:客户端发起该请求,服务端收到时间后将服务端保存的该客户端时间进度记录,并查找当前房间所有客户端的时间进度,找出最大和最小值,使用sync 事件将两个时间广播到该房间。
  • sync:客户端收到该事件,需要自行选择更新到最小还是最大时间,并且为了防止网络波动造成的小延迟,只有在差距大于一定值时才选择进行更新,这里我选择只向前更新。
  • setTime:客户端发送该请求,客户端同时接收该事件,当进度条被拖动,VideoJStimeupdate 会超过250毫秒,每当超过1秒就发送该请求到服务端,并附上新的时间,服务端对该请求进行广播,让房间内所有客户端都设定到该时间,
  • getTime:客户端发送该请求,客户端同时接收该事件,发送时会由服务端进行广播,收到该请求的所有客户端会回复time 事件,触发服务端广播的sync,从而实现房间进度同步。

看起来略有混乱,我甚至不知道能不能跑起来(最后确实是跑起来了甚至跑的不错。

不过这只是一个原型,后期打算使用CRDT进行重构。

代码实现

这里不对代码做详细介绍,感兴趣的同学可以直接查看仓库,前端就几百行,后端也很短,只有两百多行。

使用帮助

大家最关心的也许是这个吧,因为是一个Web项目,所以需要一台服务器,这是肯定的吧,并且资源的来源是第三方流媒体,所以流畅程度并不取决于服务器的带宽,服务器只用来做进度条数据的同步,可以说负载非常的低,10Kb网络都不会有问题,也可以实现毫秒级的同步。为了方便部署,我将项目编译到了Release,需要的同学可以自行下载,(这里可以开开脑洞使用树莓派或者软路由做DDNS或者内网穿透运行起来,如果实在太懒,可以直接使用给出的测试url

部署方式有两种,一种是使用Docker,另一种是手动部署,建议使用Docker部署

Docker预编译镜像

docker run -d -p 9999:9999 -e PORT=9999 -e ALLOW_ORIGIN=http://localhost:9999 --name=test vaalacat/movie-sync

一共有两个环境变量,PORT 是服务端口,ALLOW_ORIGIN 是浏览器中访问的链接(由于反向代理,可能和端口不一致,如果不理解,那就直接用http://ip:端口)。

手动部署

首先我们需要分别下载前端和后端文件,

下载完成后在服务端创建文件夹,结构为:

ProjectDir
├── asset # 下载好的前端文件放在这里吗
│   ├── css
│   ├── favicon.ico
│   ├── fonts
│   ├── index.html
│   └── js
└── movie-sync-server # 后端文件放在这里
    ├── .env # 该文件需要手动创建
    └── movie-sync-server

然后我们编辑后端文件夹中的.env 文件,内容为:

PORT=8000
ALLOW_ORIGIN=http://localhost:8000

这里的端口就是程序将监听的端口,后面的ALLOW_ORIGIN要改为客户端浏览器中访问时显示的地址,不用添加后面的路径,因为有些时候会经过Nginx之类的反向代理,所以链接可能会变成其他样子。修改完成后保存文件,然后就可以直接运行了,切换到后端文件夹:

cd ProjectDir/movie-sync-server
chmod +x movie-sync-server && ./movie-sync-server

如果你看到了这样的东西,说明已经成功运行

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /socket.io/*any           --> github.com/gin-gonic/gin.WrapH.func1 (2 handlers)
[GIN-debug] POST   /socket.io/*any           --> github.com/gin-gonic/gin.WrapH.func1 (2 handlers)
[GIN-debug] GET    /                         --> main.main.func12 (2 handlers)
[GIN-debug] GET    /movie                    --> github.com/gin-gonic/gin.(*RouterGroup).StaticFile.func1 (2 handlers)
[GIN-debug] HEAD   /movie                    --> github.com/gin-gonic/gin.(*RouterGroup).StaticFile.func1 (2 handlers)
[GIN-debug] GET    /movie/login              --> github.com/gin-gonic/gin.(*RouterGroup).StaticFile.func1 (2 handlers)
[GIN-debug] HEAD   /movie/login              --> github.com/gin-gonic/gin.(*RouterGroup).StaticFile.func1 (2 handlers)
[GIN-debug] GET    /movie/css/*filepath      --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (2 handlers)
[GIN-debug] HEAD   /movie/css/*filepath      --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (2 handlers)
[GIN-debug] GET    /movie/js/*filepath       --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (2 handlers)
[GIN-debug] HEAD   /movie/js/*filepath       --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (2 handlers)
[GIN-debug] GET    /movie/room/*any          --> main.main.func13 (2 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8000

现在访问链接http://localhost:8000 即可开始使用

- The End -