写一个全网最全的音乐播放器
前言
说起音乐播放器,其实目前已经有很多相关的轮子了。之前也看到了很多,无论是多源合一的聚合类型播放器,还是精美的模仿播放器,或多或少都有些小问题(众口难调嘛 也不奇怪):
- 源不全:音源可能不稳定,修改或新增源需要开发者修改代码(说不定还有律师函警告)
- 功能不全:其实多源聚合的播放器还有蛮多优化空间的,比如怎么解决某些源没有歌词的问题等等
- 样式有点呆:这个就……不多说了,虽然我写的也好看不到哪里去。。。
所以…如标题,不如写一个全网最全的,专注功能的,不太丑的音乐播放器。写个播放器既可以巩固学过的知识,多多思考,又能解决的自己问题,还能打发时间,何乐而不为呢,当然,为了解决上述问题,架构还是要好好设计一下的。
技术选型:React Native? Flutter?
其实我21年就曾经用flutter写过一个音乐播放器。当时是想要一个功能简单,能满足日常需求(不用来回切app)的播放器,恰好那会快毕业闲着没事干,就花了几天学了下flutter现学现卖写了一个集成了B站、咪咕和网易云的播放器。App还是很流畅的,但是弊端也很明显:过了几个月当我想再捡起来继续维护的时候,我发现已经看不懂了。在此向这位同学说声抱歉,我真不是故意鸽的,因为我真tm忘了该怎么写了…:
所以这次我选择了RN。至少在熟悉的技术栈里,我可以更容易地专注于功能和性能。恰好这次想法冒出来的时候RN发布了0.69版本(好像是,懒得看了);距离我上次使用RN也有了不少啸改动,不妨再试它一次。对于个人开发者来说,似乎也没必要讲究太多,能解决问题的就是好东西。
框架:聚合?解耦?
首先我们先回到需求,对我个人来说,我的核心诉求也很简单:
- 多源聚合:不想来回切app听歌
- 扩展性、稳定性:希望app的音源是可以低成本扩展的,甚至网络上只要可以搜得到的资源都可以播放
- 尽可能完备:希望播放器可以对所有音源提供尽可能完整的,且相似的功能(否则扩展性似乎也就不存在了)
听起来似乎很矛盾:又要尽可能多的源,又要可扩展,还不想写代码,净想好事了。事实上,破局的方法很简单:如果能把音乐相关的操作抽象出来,和app本身的功能解耦开,问题就迎刃而解了。说得再明白一点:对于一个音乐播放器来说,它所需要的核心功能:搜索(音乐、专辑、歌手)、播放、下载、歌词、导入、查看专辑歌手信息等等,这些功能对于不同的音源来说,输入和输出的结构是可以完全一致的。
考虑到可扩展性,自然而然就想到把这些核心功能按照特定的协议以插件的形式外置,这样一来,音乐播放器只需要考虑到如何管理音乐媒体,某些时机触发某些核心行为得到固定格式的输出,而所有的核心行为都由插件去实现;如果需要扩展音源或者遇到音源不稳定的情况,也只需要修改插件就好了。换句话说:只需要通过安装不同的插件就可以播放你能搜到的任何音源。
举个例子:当用户搜索“作者 猫头猫”的时候,对于播放器来说,它只需要知道要去调用这样一个函数:
1 | function serach(query, type, page): Promise<Artist[]> |
并把得到的结果渲染到屏幕上;至于这个东西是什么,那就交给插件吧。
综上所述,我们得到了大概的核心框架图:
插件:怎么实现?
在上一节中提到了,插件是实现某些特定功能的一个函数。那么接下来又是一个难题:怎么把插件钩入到app中去?回顾下技术选型,这次选择的是RN,那么这个问题其实很容易解决:反正运行时自带一个js引擎,那就直接读取外部文件,然后直接整个Function呗。试了一下,发现方案可行。具体来说,是这样一个自执行函数:
1 | const plugin = Function(` |
其中,funcCode就是从外部读入的插件。方便起见,可以把一些常用的包(网络请求、加密解密、日期转化、html解析等等)通过参数传递给插件,从而减轻插件开发的工作量:
1 | const plugin = Function(` |
到这里,从app侧解析插件的工作就做完了。接下来的问题是:插件内部(也就是上边的funcCode)应该长什么样子。所以接下来我们就需要制定具体的插件协议了。根据上边的解析方式,插件的基本结构如下:
1 | function pluginName(packages){ |
插件函数的返回值,也就是我们可以具体在app中获取到的插件信息,可以在这一部分定义一层统一的插件协议,以定义某些核心操作所触发的行为。至于什么时候调用交给app统一控制。这里就不赘述协议定义的细节了,大概贴一下demo估计就够了,具体的插件可以参考这个仓库:
1 | // packages里面预置了一些常用的包,包括axios,dayjs,cryptojs和cheerio,应该够用了 |
由于涉及到缓存,并且其实我们并没有办法完全信任第三方插件的返回结果,因此在app中基于插件又封装了一层PluginMethods,实际使用的时候调用的是这些方法。当需要开发新的功能(比如获取热门歌单…时),也只需要补充一下插件协议,然后开发对应的页面即可。插件机制在app中的完整实现可以参考这里。
最终的效果也确实符合预期(能搜到的都能播),甚至可以播放某K歌的音乐: ![IW58ZJ$40F`32SN7)G43T.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f462be5be74a4f8abff34f047325c48a~tplv-k3u1fbpfcp-zoom-1.image)
播放队列
音乐的播放使用了react-native-track-player(以下简称rntp)。这是一个功能比较完善的播放器,提供了播放音乐、播放队列管理等功能。首先来看下它的使用方式:
1 | var track1 = { |
其中url字段标记着媒体文件的播放源。这里就有了一个隐藏的问题:假如我们需要搜索某首歌(这里搜索一定是通过插件实现的)并点击播放;搜索结果中可能每一首音乐并不包含音频源的url,需要发起额外的网络请求,而这个时候如果我们为搜索结果的每一首歌都发起这样的请求,显然有些过分了。因此,获取某个音频的真实播放源的操作需要放在插件中,并且在播放前(点击播放、继续播放、播放结束切换到下一首)时执行。
rntp内置的播放队列并不支持在播放前执行一个hook,同时按照文档中的描述,它也不支持在播放时替换正在播放的音频的url。因此在这里,我们需要用一点小小的黑魔法来解决问题——舍弃rntp内置的播放队列,基于它封装一个符合我们需求的播放队列。
新的播放队列中维护着当前正在播放的音乐列表。每当播放某个音乐(musicItem)的时候,我们都需要首先根据musicItem获取真实的源(调用对应插件的getMediaSource方法),然后再把音乐信息和源信息一起送给rntp的队列中播放,至此,点击播放的问题就解决了。
然而除了点击播放之外,顺序播放依然存在问题:如果正常一首歌曲播放完成,rntp会自动播放其内部队列中的下一首歌,而不会走到封装的播放逻辑。rntp提供了一些事件,比如当歌曲切换时会触发PlaybackTrackChanged事件,播放队列结束时会触发PlaybackQueueEnded事件等等。我们可以让rntp中只存在一首歌,每当触发PlaybackQueueEnded事件时,我们就知道这首歌播放结束了,这个时候就需要自动的执行下一首歌的逻辑了。
事实上,在具体代码实现中,是使用了PlaybackTrackChanged事件来监听播放结束的。这是因为rntp的安卓部分内部使用了exo player,在它的设计中,如果当前播放队列仅仅有一首歌,并且播放模式是顺序播放的话,那么在通知栏里面不会出现切换下一首歌的图标(向右的小箭头)。为此,具体实现中rntp的内部队列始终维护两首歌(当前正在播放,当前的下一首),track变化时便是播放结束的时机,此时执行我们封装好的skipNext函数即可:
1 | TrackPlayer.addEventListener(Event.PlaybackTrackChanged, async evt => { |
歌词关联
上文中还提到,某些平台并不提供歌词(比如某些视频源)。对于这种情况,其实可以把不同源的歌词关联到一起。举个例子:我要播放B站源的歌曲,但是没有歌词。这个时候,我可以将其他源的歌词(尽管其他源可能没有版权,但是很多翻唱还是有歌词的)关联到这首歌:
这一部分的逻辑也是封装在插件方法里的,简单来讲,这些关联的歌词形成了一个链表,关联歌词本质上就是一个链表的递归: 每次都从链表末尾调用获取歌词的方法即可。需要注意的是,歌词关联可能会形成环路,当递归遇到环路时,需要识别并自动断开环。
其他细节
不得不说,要做一个完备的播放器要考虑到的东西还是挺多的,包括歌词解析、状态持久化、下载队列、缓存等等,牵扯到不少的知识点(比如递归、LRU、react、native相关甚至js引擎层的知识等等),也踩了不少坑。如果有人想知道的话再写吧,这里就不赘述了(写累了不想写了…)。
结语
目前这个播放器还是测试版本(但是基本功能已经差不多,日常使用没问题了),插件协议可能还会有变动。代码基于GPL协议开源:https://github.com/maotoumao/MusicFree/ (打不开就github换gitee),下载地址在github发布页,使用方式在readme(实在不知道咋用的话直接问我吧…)。之后打算好好维护,能解决自己和身边朋友的问题就已经达到最初的预期了。
如果你觉得这个项目还不错,欢迎给个star~ 点个关注鼓励一下也行。也可以关注b站:不想睡觉猫头猫或者公众号↓(刚整了个还没怎么发东西);不发水文,平时会更多分享一些自己做的好玩的小东西,学以致用才更有动力。谢谢你看到这里,下次想起来更新的时候再见~