深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
中
ESC

小爱音箱不肯放 MusicFree 的歌,我在 App 里自己写了个投屏协议:手机开服务器,音箱反过来连我取流

中文 Read in English

晚上想在客厅听歌,音箱是小米的 S12,手机里放歌的是 MusicFree——一个开源播放器,插件是我自己那套。它没有投屏按钮。

能凑合的路子就两条:连蓝牙,或者打开米家让它自己播。连蓝牙等于把手机变成发射器,锁屏、走开、换个 App 都听不得;米家能点到的曲库不是我的曲库,我要的东西全在我自己的插件里。

所以我打算让 MusicFree 自己有投屏。

先摸清音箱是什么,别急着写代码

我写了个小探针(castprobe,Rust,不依赖任何 UPnP 库),先干一件事:在内网里把 DLNA 接收端找出来,把它的描述文件拉下来看。

结果挺意外——小爱音箱就是一个标准的 DLNA MediaRenderer。DMR-1.50,描述文件挂在 9999 端口上,服务清单里除了标准的 AVTransport、RenderingControl、ConnectionManager,还多了小米私有的 Queue:1 / Playlist:1 / Favorites:1(都有 AddURI,理论上能把整张歌单灌进音箱让它自己连播),外加一个腾讯 QPlay:2。

也就是说:不需要小米账号,不需要 token,不用逆向 miio。 这条路是敞开的。

但发现这一步差点把我劝退。从 160 往 239.255.255.250:1900 发组播 M-SEARCH,0 响应;换成 255.255.255.255 广播,还是 0 响应。最后是逐 IP 单播扫 192.168.1.1–254 才把它应出来。这直接决定了后面代码怎么写:纯靠监听 SSDP 组播,在有些设备上永远点不出设备,ControlPoint 必须自带单播扫描兜底。

架构挨了一次纠偏

第一版方案我想的是「在 160 上跑一个常驻服务」:机器在那儿、nginx 在那儿、曲库也在那儿,顺手。后来定死成另一个样子:全部塞进手机 App 里——Kotlin 写 ControlPoint,App 自己起一个 HTTP relay,160 只当编译机。

理由很实在:语音点歌那一路早就存在了,缺的只是「播到哪儿」。再多一台常驻服务,就多一个要盯着会不会挂的东西。手机本来就是队列的主人,它最该知道自己手上这首歌的真 URL 是什么。

真跑起来,一个个坑排着队来

SOAP 调用全部返回空

SetAVTransportURI 返 200,Play 返 200,可进度永远是 0。

查下去才明白:Java 的 DocumentBuilderFactory 默认不是 namespace-aware 的。getElementsByTagName("GetPositionInfoResponse") 匹配不到音箱回的 u:GetPositionInfoResponse——它带前缀。必须剥掉前缀比 localName:tagName.substringAfterLast(':')。

这个坑最难看的不是难度,是它的表现:它不抛异常,只是什么都不返回。看着像”音箱没在播”,其实是”我没读懂音箱的话”。

投屏态点播放,只发了 Play,没发 URI

投屏时本机播放器挂的是 musicfree://proposed-audio 这种占位源,真 URL 是推给音箱的。但 trackPlayer.play() 里有个「点的就是当前这首歌就直接 resume」的快捷分支——于是音箱收到一个没有源的 Play,永远不出声。

修法两条:续播那条加分支条件 getState() !== "idle";非投屏的 isCurrentMusic 快捷分支用 !castService.isCasting 挡掉,让它老老实实重新取源走 setTrackSource → playTrack。

音箱只认 http,而我下载过的歌是 file://

插件对已经下载的歌给的地址是 file:///storage/emulated/0/.../xxx.mp3。音箱不会读我手机的文件系统。所以 relay 加了本机文件模式:publishFile() 登记一个路径,换回一个 http://<手机IP>:<随机端口>/stream/f1;serveFile() 用 RandomAccessFile 按 Range 切 206 出去。

这里有个协议语义值得单独记:音箱取流的请求头固定是 User-Agent: xiaoai、Range: bytes=0-、Connection: close,而且它播一段会先断开、再带着 bytes=46414- 回来续取。服务必须支持开区间 Range 并回 206 + Content-Range。

我最早偷懒,随手 python3 -m http.server 挂了一首歌测。音箱连上了,状态 PLAYING,RelTime 死在 00:00:00——因为它不理 Range,音箱拿不到它要的那段就干脆不推进。换成回 206 的实现,RelTime 按 1/3/5/7/9/11 走,正常出声。“连上了没声音”和”没连上”是两种完全不同的病。

「播完」误报

小爱放完一首不会变 STOPPED,它原地从头重播,状态一路 PLAYING。所以只能靠进度回绕来判断播完。第一版只要看到进度掉回 0 就当下曲结束——结果 App 退到后台轮询被拖慢、或者有人在米家里把音箱从头播一次,就被误判成”播完”,抢着切了下一首。

现在多加一个条件:上一轮的进度必须已经落在结尾 20 秒以内(position >= duration - 20),才算真的循环回绕。

我冤枉了路由器两次

一次是断定「音箱不收内网流」。真因是 160 的 ufw 默认 deny 入站,只放行了 22 / 53317 / 65422,音箱的连接被我自己的服务器挡在门外——relay 日志一条请求都没有,才暴露出来。

一次是手机对音箱 ip neigh ... FAILED、ping 100% 丢包,我当场断定 AP 做了客户端隔离。几分钟后,同一台手机 0% 丢包,9999 端口 OPEN。不是隔离,是音箱省电/ARP 老化一类的抽风。

现在凡是「网络不通」的结论,我都先重跑一遍 ping 再开口。

跑通的那一刻

真机:PJZ110 + S12,2026-10-18 07:17。

我在 160 上单独写了个脚本,只干一件事:每秒轮一次音箱的 GetPositionInfo 并打印。故意不经过 App,免得 App 自己骗自己。

链是这样走通的:手机里的 Kotlin ControlPoint 单播扫到音箱 → SetAVTransportURI,DIDL-Lite 里带上标题、歌手、专辑,媒体地址填 http://192.168.1.179:<随机端口>/stream/f1 → Play → 160 那边独立轮询打回来:PLAYING,TrackDuration = 00:04:29,RelTime 一秒一秒往前推,TrackURI 就是我手机自己的地址。

音箱是回过头来连我手机取流的。 没装服务端,没 root,没小米账号,没 token。

迭代一次的代价

顺手记一下节奏,供后面继续折腾的人参考:Android 侧是 Kotlin,在 160 上 ./gradlew assembleDebug,增量编译 23 秒,装 app-arm64-v8a-debug.apk;JS 侧根本不用重新打包——debug 包不内嵌 bundle,Metro 跑在 160 的 8082,手机 adb reverse tcp:8081 tcp:8082,改完重启 App 就生效。业务代码的迭代成本几乎为零,这才是敢反复改的原因。

三个入口

播放条上的投屏图标 → 设备面板,选设备、连上、记住它,下次开 App 直接投回这台。

投屏设备面板:小爱音箱-5074 · S12 · 192.168.1.183

歌曲右边的 ⋮ 菜单里加了「投到音箱播放」:已经在投,就把这一首直接推过去;没投,先连上次记住的设备,连不上再打开面板,选完设备接着播这一首。我不想为了放首歌先喊一嗓子。

歌曲菜单里的「投到音箱播放」

语音点歌 musicfree://play?q=歌名 那条老路也接上了:搜索、播放,源直接落到音箱,不用再回到手机上点一下。

还没验的

  • Seek 返 200,落点准不准没细测。
  • 投屏中的队列连播。小米其实挂了私有的 Queue:1 / Playlist:1,AddURI 能把整张歌单灌进音箱让它自己播,我只做了单首推流,这条更省事的路还没走。
  • 「回到本机播放」退得回去,但还没天天用。
  • RN 的面板/弹窗在 uiautomator dump 里一个节点都没有,自动化只能靠截图像素定位。为了点一个按钮我写了个纯 Node 解 PNG 找包围盒的脚本——测试脚本有时候比功能难写。

MusicFree 是开源的,缺的功能不会因为我需要就有人来加。但 DLNA 是公开协议,音箱就大大方方在 9999 端口把描述文件挂着,服务、动作、参数写得明明白白告诉你它听得懂什么。

协议摆在那儿,写就是了。

文章作者:阿文
文章链接: https://www.awen.me/post/9c4e2b17.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客!

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️