当手机里存了一部电影,想投到客厅电视上看,最常见的路径是打开视频App、点右上角TV图标、再祈祷列表里出现电视设备。可如果这部电影只是躺在本地磁盘里,很多App根本没这个入口。我也试过装各种投屏工具,要么安装包大得离谱,要么第一次启动就弹广告和全家桶,体验堪称灾难。最近我花了几个晚上,从零写了一个开源的局域网投屏项目,整个项目走的是最小可用(MVP)路线。目标非常明确:不依赖任何云端服务,只要两台设备在同一个局域网内,就能把一个本地视频从发送端“甩”到接收端播放,并且在发送端完成播放、暂停、拖动进度等基本控制。这篇文章先把MVP阶段的架构拆开讲清楚。
1. MVP不是功能少,是边界清楚
1.1 现成方案看着很多,真用起来全都不顺手
DLNA、AirPlay、Chromecast这些标准协议各有各的问题。DLNA在电视上覆盖面够广,但很多电视的DLNA实现只是“能播”,播放状态和进度控制经常出错;AirPlay只对苹果系设备友好,Windows和Android想用还得装第三方软件;Chromecast体验不错,但要求局域网里必须有一台专用设备,而不是任意一台带浏览器的设备。更关键的是,这些协议并不是为“程序员自己搭一个接收端”准备的,想在开源项目里做一个完整接收端,光处理协议细节就得花好几个星期。
于是我不再纠结通用协议,而是自己设计了一个非常窄的MVP:它只解决“请帮我播放这个视频文件”这一个请求。整个项目从写第一行代码到跑通完整链路,只花了一个周末的时间。核心原因是,投屏这个场景在最小意义上,无非是把一个可播放的URL告诉另一台设备,再给它发几条控制指令。
1.2 功能边界的四象限
把项目要做的功能用四象限过一遍,会发现真正不能砍的核心只有四件:设备发现、媒体服务、控制信令、播放页面。设备发现让发送端能自动找到接收端;媒体服务负责把视频文件变成可播放的流;控制信令让发送端能发“播放、暂停、跳转”指令;播放页面则是接收端最终的落地载体。这四个环节连起来就是一个闭环。
至于多房间播放、弹幕、字幕加载、转码、播放列表、用户账户、断点续传,这些全部属于“可以做但不影响最小闭环”的功能。在MVP阶段,我唯一的原则是:如果砍掉它,用户仍然能把本地视频投到另一块屏幕上,就坚决不提前做。这个原则帮助我在编码时保持专注,不会在项目刚开始时陷入完美主义。
1.3 一屏说清楚用户会怎么用
整个流程比想象中更像“两个浏览器页面之间的对话”。接收端设备打开接收页,页面上显示这台设备的名称和“等待投屏”字样。发送端设备打开控制页,控制页里会直接列出局域网里已经发现的接收端,点击设备后进入视频文件选择列表。从列表里选中视频,点击“投屏”按钮,接收端页面就会开始播放。这时候发送端页面出现一个控制条,可以暂停、恢复、拖进度条。
全程不依赖账号,不需要安装App,只要两台设备能同时访问同一个局域网IP。这也是我认为这个MVP最大的价值:它把投屏这件事从“协议战争”简化成了“一个URL加两条指令”。
2. 整体架构与协议选型:三端分离
2.1 核心模型:投屏的本质是“遥控器”和“屏幕”分离
理解投屏架构,先记住一句话:控制流和媒体流必须分开。接收端页面负责拉流和播放,发送端页面负责下发指令。如果把发送端当成遥控器,把接收端当成屏幕,那么视频数据像是从远端水管直接送到屏幕里的水,并不需要经过遥控器;遥控器只需要轻轻按一下,告诉屏幕“开始放水”。这个分离意味着发送端就算退出页面,已经加载的视频也可以继续播放,只有控制信号会中断。
所以项目被切成了三个逻辑角色:发送端(也叫控制端)、接收端(也叫播放端)、中间服务(同时承担媒体服务、信令转发和自动发现)。在MVP阶段,中间服务和发送端、接收端可以部署在同一台设备上,也可以分别部署,协议上都一样。只有当这三个角色彻底分离之后,后面增加多设备、多会话才会容易。
2.2 技术选型:我为什么没选WebRTC和DLNA
有朋友问我,WebRTC不是现成的点对点音视频方案吗?为什么不用?WebRTC是为了“实时双向音视频”设计的,要做信令协商、ICE候选人交换、媒体协商,就算在局域网内,也要处理一大堆SDP和ICE的状态。而我们这里其实是“单向拉流播放”,把WebRTC塞进来相当于用重型卡车运一瓶水,明显不划算。
DLNA/UPnP那套SOAP信令同样很重。它要维护大量状态变量和事件订阅,厂商实现参差不齐。AirPlay和Chromecast更谈不上轻量,自己实现接收端几乎等于逆向商业协议。于是我自己定义了三个轻量组件:UDP广播负责发现,HTTP文件服务负责媒体流,WebSocket负责控制信令。每个组件只干一件事,出现问题也容易定位。
2.3 技术栈:为什么选用 Node.js
控制页面和播放页面都是纯浏览器页面,天然跨平台,电视、手机、电脑都能打开。服务端选了Node.js,理由是:一个Node进程里可以同时启动HTTP服务、WebSocket服务和UDP协议栈,依赖最小,启动最快。用Python当然也能写,但WebSocket库和HTTP原生模块的配合不如Node顺手。
需要的依赖极少:express处理路由和静态文件,ws库做WebSocket,dgram是Node内置的,连第三方都不用装。目录结构也保持简单:服务端入口、信令处理器、媒体路由、两个HTML页面。所有代码加起来不到三千行,这也符合MVP“一行都不多写”的原则。从架构角度看,Node.js这种“单进程多角色”的方式,特别适合小体量工具型项目。
2.4 数据流:一条链路上的三份职责
当发送端点击“投屏”按钮时,实际发生的事情是这样的:发送端页面生成媒体URL——格式是http://<服务器IP>:<端口>/media/<文件名>,然后通过WebSocket向服务端发一条指令,服务端把这条指令原样转发给接收端;接收端收到URL后,把video元素的src赋成这个URL并调用play;与此同时,发送端页面的控制条进入可操控状态。以后每次点击播放或暂停,都是走同一条WebSocket通道下发。整个链路非常清晰,任何一个环节出问题,都可以独立排查。
3. 关键环节实现:从发现到播放的完整闭环
3.1 设备发现:UDP广播比组播更省心
设备发现是很多人第一反应觉得难的部分,实际上最简单的办法是UDP广播。接收端页面打开后,负责启动广播的服务每隔三秒往局域网广播地址255.255.255.255发送一个很小的JSON包,内容包括设备名、IP地址、HTTP端口、支持能力。发送端在同一端口监听,收到包后更新设备列表。
相比组播,UDP广播的优势是不依赖路由器的IGMP协议,家用WiFi环境下几乎都能用。唯一的缺点是广播包不能跨子网,但这对于“同一WiFi下投屏”这个场景正好够用。如果设备被防火墙拦了,最简单的方式是提供手动输入IP的兜底入口,我在MVP里也加了,实际调试时帮了大忙。
具体发送广播的代码大致长这样:
const dgram = require('dgram'); const discoverySocket = dgram.createSocket('udp4'); discoverySocket.bind(DISCOVERY_PORT, () => { discoverySocket.setBroadcast(true); }); const discoverPacket = Buffer.from(JSON.stringify({ protocol: 'mini-screen/1.0', deviceName: os.hostname(), host: getLocalIp(), port: HTTP_PORT })); setInterval(() => { discoverySocket.send( discoverPacket, 0, discoverPacket.length, DISCOVERY_PORT, '255.255.255.255' ); }, 3000);为什么要带protocol字段?因为以后局域网里可能会有多个投屏工具同时广播,接收端需要识别是不是自己的协议。这是一个容易忽略的细节。如果不加版本字段,等协议升级时就会陷入“你看不懂老设备、老设备也不懂你”的尴尬。
3.2 媒体服务:Range请求是你播放器的救命稻草
播放视频和播放网页里的图片不一样,HTML5 video播放远程URL时,浏览器会先发送一个带着Range头的请求来探测服务器是否支持分段读取。如果服务器直接返回200和完整文件,且没有Accept-Ranges头,浏览器大概率会放弃拖动,甚至在一些情况下连正常播放都卡顿。所以Range支持不是可选项,是必选项。
在处理媒体请求时,需要解析bytes=start-end,并根据文件大小算出正确的结束位置,返回206 Partial Content,同时在响应头里带上Content-Range和Accept-Ranges: bytes。文件流用fs.createReadStream从指定位置开始读。这样经过测试,MP4文件在拖动进度条时基本能做到秒跳。
另一个容易忽略的是Content-Type。有些浏览器看扩展名也能猜,但规范起见还是要做一份MIME映射。.mp4对应video/mp4,.webm对应video/webm,.mkv对应video/x-matroska。如果返回类似application/octet-stream,接收端可能把它当成下载文件而不是媒体流,页面里的播放器会直接黑屏。这里给一个简化的处理逻辑:
app.get('/media/:filename', (req, res) => { const filePath = path.join(MEDIA_DIR, req.params.filename); const stat = fs.statSync(filePath); const fileSize = stat.size; const range = req.headers.range; if (range) { const [start, endStr] = range.replace(/bytes=/, '').split('-'); const startByte = parseInt(start, 10); const endByte = endStr ? parseInt(endStr, 10) : fileSize - 1; res.status(206) .set('Content-Type', getMime(req.params.filename)) .set('Content-Range', `bytes ${startByte}-${endByte}/${fileSize}`) .set('Accept-Ranges', 'bytes') .set('Content-Length', endByte - startByte + 1); fs.createReadStream(filePath, { start: startByte, end: endByte }).pipe(res); } else { res.status(200) .set('Content-Type', getMime(req.params.filename)) .set('Content-Length', fileSize); fs.createReadStream(filePath).pipe(res); } });为什么MVP阶段不做转码?因为转码意味着集成FFmpeg、处理CPU占用、管理转码进度和多码率切换,复杂度直接翻几倍。MVP只需要能播放H.264/AAC编码的MP4,这种格式在浏览器兼容性最好。对于不支持的格式,直接提示“请先转码”,在最小可用阶段完全能接受。
3.3 控制信令:WebSocket上用JSON说话
控制信令选择了WebSocket,理由很简单:它天生就是双向的,服务端可以主动推送状态给前端。MVP阶段不需要把所有状态都同步,但至少要做到四件事:按需载入视频、播放、暂停、跳转。消息全用JSON字符串,格式定得很简单:{"cmd":"load","url":"..."}、{"cmd":"play"}、{"cmd":"pause"}、{"cmd":"seek","time":123.4}。
服务端负责维护两个关键连接:接收端连接和发送端连接。接收端连接后先发一条注册消息,发送端连接后也发一条注册消息,服务端把两者都记下来;发送端后续的所有指令会被原样转发给接收端。这个设计甚至不需要区分“房间”,因为MVP就是一对一。但我在代码里保留了一个room字段,为后面多设备场景做预留,代价几乎为零。
接收端处理指令时直接操作DOM:
ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.cmd === 'load') { video.src = msg.url; video.play(); } else if (msg.cmd === 'pause') { video.pause(); } else if (msg.cmd === 'play') { video.play(); } else if (msg.cmd === 'seek') { video.currentTime = msg.time; } };这里有一个重要细节:接收端页面必须在用户点击一次后才能真正播放视频,这是浏览器自动播放策略。所以我在接收端页面里加了一个明显的“开始接收”按钮。这个按钮不只是仪式感,它本质上是在帮浏览器完成一次用户手势解锁。如果不这么做,程序里调用video.play()会被浏览器拦下,Promise返回reject,用户看到的就只是一个黑屏。
3.4 进度同步:做到“够用”而不是“完美”
投屏场景下,发送端的控制条如果完全不知道播放到哪,体验会很差。MVP阶段不需要精确同步,但至少要让发送端看到当前进度。最简单的方案是接收端定时通过WebSocket回传播放位置:{"type":"progress","time":currentTime},发送端收到后更新进度条。发送端拖动进度条时发送seek指令,接收端把currentTime改成对应秒数。
这里不需要像视频会议那样做毫秒级同步,因为观看场景下几百毫秒的偏差完全感知不到。真正的复杂点在于播放结束时清理状态,比如视频播完后发送端控制条要恢复到初始状态。MVP里我直接在video的ended事件里给WebSocket发一条{"type":"ended"},发送端收到后把按钮置灰。这个量级的同步,已经覆盖了用户大部分使用场景。
4. 实操记录:从零跑通遇到的坑和解决思路
4.1 启动项目:一条命令之后,还要手动点开两个页面
整个项目的启动命令非常朴素:
npm install npm start启动之后控制台会打印两个地址,一个是接收端页面,一个是发送端页面。但“一条命令启动”和“打开就能用”之间还是有距离。接收端页面必须先在目标设备上打开,才能被发送端发现;如果接收端页面都没打开,发送端自然找不到设备。这个顺序在第一次测试时很容易被忽略,我一度以为是发现机制有bug,后来才发现接收端根本不在线。
调试的时候,我建议先把接收端页面固定打开,再操作发送端,这样能减少变量的干扰。
4.2 播放器不动了?先检查自动播放策略
第一次联调时,我在发送端点击“投屏”,接收端页面的video元素拿到的src正确,却没有任何画面。打开控制台才发现play()返回的Promise被reject了,错误是自动播放限制。因为我在接收端页面一加载完就执行了video.play(),但浏览器要求播放必须由用户手势触发,不是程序想放就能放。
解决方法是让接收端页面先呈现一个明显的开始按钮,用户点击后再把按钮隐藏,并给video绑定点击事件。发送端发来的play指令仍然需要调用play,但因为页面已经有过用户交互,这个调用就不会被拦。这个设计看似多了一步操作,但在浏览器规则面前只能顺着来。
4.3 地址拼接:一个localhost引发的“惨案”
还有一次测试时,发送端是电脑,接收端是手机。电脑上能正常播放,手机却始终黑屏。我打开手机浏览器控制台,发现请求的媒体地址是http://localhost:8080/media/xxx.mp4,这个URL在手机上当然指向手机自己,当然拿不到文件。问题出在生成URL时直接用了后端的req.get('host'),请求来自电脑浏览器,host就是localhost。
把URL生成改成从服务端的网络接口获取局域网IP,这个坑才算填平。代码可以用os.networkInterfaces()过滤出非内部的IPv4地址,然后拼出http://<ip>:<port>/media/<filename>。从那以后,我学到一条经验:任何需要跨设备访问的地址,都不要相信请求头里的Host,而要主动取机器IP来拼接。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 发送端找不到接收端 | 广播被防火墙拦;两台设备不在同一网段;接收端页面没打开 | 先确认接收端页面能访问;暂时关闭防火墙;手动输入接收端IP |
| 手机能发现设备,但点投屏没反应 | WebSocket连接没建立;接收端页面还在等待用户点击 | 打开两个页面的开发者工具看ws状态;确认接收端已点击“开始接收” |
| 视频一直转圈无法播放 | 视频是H.265编码,浏览器不支持;Content-Type不对 | 换成H.264/AAC的MP4;检查HTTP响应头 |
| 播放能正常但拖动进度条卡死 | 没实现Range请求或返回了200 | 按上文代码实现206,并用curl验证 |
| 发送端进度条永远不动 | 接收端没有上报progress;ws断开了 | 检查接收端控制台是否定时发消息;增加心跳重连 |
| 局域网里其他设备访问不了服务 | 防火墙阻断了TCP端口 | 放行监听端口;部分设备需要关闭AP隔离 |
这张表基本就是实际调试中反复踩过的坑。按着它查,能省很多时间。
4.5 调试媒体流的小技巧
服务端日志里最值得关注的是Range请求头。用curl手动模拟一段请求:
curl -v -H "Range: bytes=0-99" http://127.0.0.1:8080/media/sample.mp4 -o /dev/null如果响应行是HTTP/1.1 206 Partial Content,说明服务端Range处理正常。如果出现200,就该去查代码里有没有正确读取req.headers.range。浏览器开发者工具里的Network面板同样能直接看媒体请求的响应头,我调试时一般会把媒体请求单独拎出来盯几轮,确认响应头里的Content-Type和Content-Range是否符合预期。
5. 从MVP到能落地:还要补哪些隐形架构
5.1 多设备与投屏会话管理
MVP版本默认只有一个接收端和一个发送端,一旦局域网里出现第二台接收端,管理关系就需要重新设计。我建议在发现包中增加一个稳定的设备ID,发送端根据设备ID展示列表,用户选择后建立“投屏会话”。同一个接收端被第二个发送端控制时,接收端要返回“已被占用”的消息,而不是直接执行指令。
这个占用状态还要设置超时。如果发送端页面直接关掉,WebSocket断开,服务端应该在一段时间后自动释放接收端。否则接收端会一直卡在“被某个不存在的设备控制”的状态里,用户只能刷新页面自救。这个设计不算复杂,但非常重要。
5.2 心跳与断线重连
MVP里WebSocket断开后会留下许多假象,比如界面还显示“已连接”,但实际上指令已经发不过去了。补一个心跳机制成本极低:接收端每隔5秒发一条{"type":"ping"},服务端超过15秒没收到就标记离线。发送端发现设备离线后,提示用户重新建立连接,并且重新发送一次load指令来恢复播放状态。
这里要特别强调:媒体流本身走的是HTTP,不依赖WebSocket,所以即使控制连接断了,视频还会继续播放。重连后如果什么都不做,发送端会失去控制权。因此重连后必须把当前播放状态(暂停/播放、当前进度)整体同步一次,才能让两端回到可控状态。
5.3 设备发现机制,提前想好mDNS扩展
UDP广播在同一个子网里很好用,但它不标准。如果想被其他系统自带发现面板识别,方向是迁移到mDNS/DNS-SD协议,比如向_miniscreen._tcp.local发布一条服务记录。差异主要在于DNS SRV/TXT记录的构造,但可以在MVP时就把发现模块独立出来,接口提前抽象成discoveryProvider.start()、discoveryProvider.stop()、discoveryProvider.on('device', ...),以后从UDP广播切到mDNS只是替换一个Provider。
这一步属于明显的“为未来铺路”,不会增加太多复杂度,但我不建议在MVP里实现mDNS,因为依赖库会变得更大,而且大多数家用场景用不上。有抽象就够了。
5.4 转码支持,是个更大的工程
浏览器不支持的视频格式是这个项目目前最大的坎。MVP阶段只要求接收端能播H.264/AAC编码的MP4,如果用户丢进来一个H.265的MKV,就只能看到黑屏。更友好的做法是先做“格式检测+友好提示”,让发送端在点击投屏时对文件后缀做白名单过滤;再往下才是在服务端集成FFmpeg做按需转码,但转码会引入CPU占用、转码进度和延迟问题,不是一个小工程。
真要做到多格式通用,我建议把架构拆成“媒体探测、转码调度、流服务”三段来看。媒体探测负责识别编码格式,转码调度决定要不要转、转成什么参数,流服务负责输出。这已经不是MVP该碰的范畴,但如果你计划长期维护这个项目,早晚要面对。
6. 写在最后:这个MVP给我上了哪几课
回头再看这个项目,最让我意外的不是“写代码”本身,而是“定边界”的价值。在没有明确需求的前提下,我中途动过好多次加功能的念头:给接收端加弹幕,给控制页加多房间调度,甚至想做一套投屏历史记录。但每次想到“用户从发送端选文件到接收端播放”这个闭环,我就又把功能拖回未来清单里。事实证明,一个周末跑通核心链路之后,再回头补这些功能,路径都清晰得多。
还有一课是关于局域网开发的调试习惯。你觉得自己写了个无懈可击的代码,最后问题往往出在设备名拼错了、端口没放行、或者接收端页面根本没打开这些低级错误上。所以在任何一轮自测里,我都会先把两台设备的网络连通性确认好:发送端能不能ping通接收端,能不能直接在浏览器里打开服务端的HTTP页面,再做功能测试。这套方法帮我省下的时间,比我写代码的时间还多。
如果你正准备做一个类似的MVP,我的建议很简单:不要一开始就想兼容所有投屏协议,不要想着一次做到生产级,先把“选文件、播放、暂停、拖动进度”这条链路用最简单的技术打通。只有链路通了,架构上所有决策才有继续讨论的意义。