写一个全网最全的音乐播放器

web 1920.png

前言

说起音乐播放器,其实目前已经有很多相关的轮子了。之前也看到了很多,无论是多源合一的聚合类型播放器,还是精美的模仿播放器,或多或少都有些小问题(众口难调嘛 也不奇怪):

  • 源不全:音源可能不稳定,修改或新增源需要开发者修改代码(说不定还有律师函警告)
  • 功能不全:其实多源聚合的播放器还有蛮多优化空间的,比如怎么解决某些源没有歌词的问题等等
  • 样式有点呆:这个就……不多说了,虽然我写的也好看不到哪里去。。。

所以…如标题,不如写一个全网最全的,专注功能的,不太丑的音乐播放器。写个播放器既可以巩固学过的知识,多多思考,又能解决的自己问题,还能打发时间,何乐而不为呢,当然,为了解决上述问题,架构还是要好好设计一下的。

技术选型:React Native? Flutter?

其实我21年就曾经用flutter写过一个音乐播放器。当时是想要一个功能简单,能满足日常需求(不用来回切app)的播放器,恰好那会快毕业闲着没事干,就花了几天学了下flutter现学现卖写了一个集成了B站、咪咕和网易云的播放器。App还是很流畅的,但是弊端也很明显:过了几个月当我想再捡起来继续维护的时候,我发现已经看不懂了。在此向这位同学说声抱歉,我真不是故意鸽的,因为我真tm忘了该怎么写了…: image.png

所以这次我选择了RN。至少在熟悉的技术栈里,我可以更容易地专注于功能和性能。恰好这次想法冒出来的时候RN发布了0.69版本(好像是,懒得看了);距离我上次使用RN也有了不少啸改动,不妨再试它一次。对于个人开发者来说,似乎也没必要讲究太多,能解决问题的就是好东西。

框架:聚合?解耦?

首先我们先回到需求,对我个人来说,我的核心诉求也很简单:

  • 多源聚合:不想来回切app听歌
  • 扩展性、稳定性:希望app的音源是可以低成本扩展的,甚至网络上只要可以搜得到的资源都可以播放
  • 尽可能完备:希望播放器可以对所有音源提供尽可能完整的,且相似的功能(否则扩展性似乎也就不存在了)

听起来似乎很矛盾:又要尽可能多的源,又要可扩展,还不想写代码,净想好事了。事实上,破局的方法很简单:如果能把音乐相关的操作抽象出来,和app本身的功能解耦开,问题就迎刃而解了。说得再明白一点:对于一个音乐播放器来说,它所需要的核心功能:搜索(音乐、专辑、歌手)、播放、下载、歌词、导入、查看专辑歌手信息等等,这些功能对于不同的音源来说,输入和输出的结构是可以完全一致的。

考虑到可扩展性,自然而然就想到把这些核心功能按照特定的协议以插件的形式外置,这样一来,音乐播放器只需要考虑到如何管理音乐媒体,某些时机触发某些核心行为得到固定格式的输出,而所有的核心行为都由插件去实现;如果需要扩展音源或者遇到音源不稳定的情况,也只需要修改插件就好了。换句话说:只需要通过安装不同的插件就可以播放你能搜到的任何音源

举个例子:当用户搜索“作者 猫头猫”的时候,对于播放器来说,它只需要知道要去调用这样一个函数:

1
function serach(query, type, page): Promise<Artist[]>

并把得到的结果渲染到屏幕上;至于这个东西是什么,那就交给插件吧。

综上所述,我们得到了大概的核心框架图: 1.drawio.png

插件:怎么实现?

在上一节中提到了,插件是实现某些特定功能的一个函数。那么接下来又是一个难题:怎么把插件钩入到app中去?回顾下技术选型,这次选择的是RN,那么这个问题其实很容易解决:反正运行时自带一个js引擎,那就直接读取外部文件,然后直接整个Function呗。试了一下,发现方案可行。具体来说,是这样一个自执行函数

1
2
3
4
5
6
7
8
const plugin = Function(`
                'use strict';
                try {
                  return ${funcCode};
                } catch(e) {
                  return null;
                }
    `)()()

其中,funcCode就是从外部读入的插件。方便起见,可以把一些常用的包(网络请求、加密解密、日期转化、html解析等等)通过参数传递给插件,从而减轻插件开发的工作量:

1
2
3
4
5
6
7
8
const plugin = Function(`
                'use strict';
                try {
                  return ${funcCode};
                } catch(e) {
                  return null;
                }
    `)()({CryptoJs, axios, dayjs, cheerio, bigInt, qs})

到这里,从app侧解析插件的工作就做完了。接下来的问题是:插件内部(也就是上边的funcCode)应该长什么样子。所以接下来我们就需要制定具体的插件协议了。根据上边的解析方式,插件的基本结构如下:

1
2
3
4
5
6
7
function pluginName(packages){
// 一些辅助函数或者全局变量

    return {
      // ... 插件要实现的核心功能
    }
}

插件函数的返回值,也就是我们可以具体在app中获取到的插件信息,可以在这一部分定义一层统一的插件协议,以定义某些核心操作所触发的行为。至于什么时候调用交给app统一控制。这里就不赘述协议定义的细节了,大概贴一下demo估计就够了,具体的插件可以参考这个仓库

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
// packages里面预置了一些常用的包,包括axios,dayjs,cryptojs和cheerio,应该够用了
function pluginName(packages){
    // 在这里可以编写一些逻辑,存储全局变量之类的;
    return {
        platform: '插件名称', // [必选] 插件名,这个名字没取好,于是将错就错了
        cacheControl: 'no-cache', // [可选] 插件的缓存控制方案,用来缓存插件信息
        version: '0.0.0', // [可选] 插件版本号
        primaryKey: ['id'], // [可选] 主键名,可在此字段中填写插件函数入参中必备的字段
        appVersion: '>0.0', // [可选] 兼容此插件的app版本号,预防后续协议更新出现不兼容格式时报错的情况。
        defaultSearchType: 'music', // [可选] 插件在搜索时,首屏默认请求的搜索类型。
        /** 搜索 */
        search: function(query, page, type) { // 三个参数分别为: 查询的keyword,当前页码,搜索类型
            if(type === 'music') {
                // 网络请求
                return {
                    isEnd: true, // 分页请求是否结束
                    data: [], // MusicItem类型的列表
                }
            }
            if(type === 'album') {
                // 网络请求
                return {
                    isEnd: true, // 分页请求是否结束
                    data: [], // AlbumItem类型的列表
                }
            }
            if(type === 'artist') {
                // 网络请求
                return {
                    isEnd: true, // 分页请求是否结束
                    data: [], // ArtistItem类型的列表
                }
            }
        },
        /** 获取真实的播放源 */
        getMediaSource: function (musicItem) { // 入参:搜索结果中MusicItem类型的音乐
            return {
                headers: undefined, // [可选] headers
                url: 'https://', // 真实url,
                userAgent: undefined, // [可选] 如果不填,会取headers的user-agent字段
                
            }
        },
        /** 根据mediaBase信息(包括primaryKey) 获取歌曲的详细信息 [!!此函数暂时用不到 ] */
        getMusicInfo: function (mediaBase) {
            return musicItem;
        },
        /** 获取歌词 */
        getLyric: function (musicItem){

            return {
                lrc: 'http:///', //歌词源,
                rawLrc: '[00:00.000]这是一句歌词', //纯文本的歌词
            }
        },
        /** 获取专辑详细信息 */
        getAlbumInfo: function (albumItem) {

            return {
                ...albumItem,
                musicList: []
            }
        },
        /** 查询作者的详细信息 */
        getArtistWorks: function (artistItem, page, type) {
            if(type === 'music') {
                // 网络请求
                return {
                    isEnd: false,
                    data: [], // MusicItem类型音乐列表
                }
            } 
            if(type === 'album') {
                // 网络请求
                return {
                    isEnd: false,
                    data: [], // AlbumItem类型音乐列表
                }
            }
        },
        /** 导入单曲 */
        importMusicItem: function (urlLike) {
            return musicItem;
        },
        /** 导入歌单 */
        importMusicSheet: function (urlLike) {
            const musicItems = [];
            return musicItems;
        }
       /** more */
    }
}

由于涉及到缓存,并且其实我们并没有办法完全信任第三方插件的返回结果,因此在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
2
3
4
5
6
7
8
9
10
11
var track1 = {
    url: 'http://example.com/avaritia.mp3', // Load media from the network
    title: 'Avaritia',
    artist: 'deadmau5',
    album: 'while(1<2)',
    genre: 'Progressive House, Electro House',
    date: '2014-05-20T07:00:00+00:00', // RFC 3339
    artwork: 'http://example.com/cover.png', // Load artwork from the network
    duration: 402 // Duration in seconds
};
await TrackPlayer.add([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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 TrackPlayer.addEventListener(Event.PlaybackTrackChanged, async evt => {
        if (
            evt.nextTrack === 1 &&
            !(await TrackPlayer.getTrack(evt.nextTrack))?.url
        ) {
            if (MusicQueue.getRepeatMode() === 'SINGLE') {
                await MusicQueue.play(undefined, true);
            } else {
                const queue = await TrackPlayer.getQueue();
                // 要跳到的下一个就是当前的,并且队列里面有多首歌
                if (
                    isSameMediaItem(
                        queue[1] as unknown as ICommon.IMediaBase,
                        MusicQueue.getCurrentMusicItem(),
                    ) &&
                    MusicQueue.getMusicQueue().length > 1
                ) {
                    return;
                }

                await MusicQueue.skipToNext();
            }
        }
    });

歌词关联

上文中还提到,某些平台并不提供歌词(比如某些视频源)。对于这种情况,其实可以把不同源的歌词关联到一起。举个例子:我要播放B站源的歌曲,但是没有歌词。这个时候,我可以将其他源的歌词(尽管其他源可能没有版权,但是很多翻唱还是有歌词的)关联到这首歌:

Screenshot_20221004_173946_fun.upup.musicfree.jpg

这一部分的逻辑也是封装在插件方法里的,简单来讲,这些关联的歌词形成了一个链表,关联歌词本质上就是一个链表的递归: 2.drawio.png 每次都从链表末尾调用获取歌词的方法即可。需要注意的是,歌词关联可能会形成环路,当递归遇到环路时,需要识别并自动断开环。

其他细节

不得不说,要做一个完备的播放器要考虑到的东西还是挺多的,包括歌词解析状态持久化下载队列缓存等等,牵扯到不少的知识点(比如递归、LRU、react、native相关甚至js引擎层的知识等等),也踩了不少坑。如果有人想知道的话再写吧,这里就不赘述了(写累了不想写了…)。

结语

目前这个播放器还是测试版本(但是基本功能已经差不多,日常使用没问题了),插件协议可能还会有变动。代码基于GPL协议开源:https://github.com/maotoumao/MusicFree/ (打不开就github换gitee),下载地址在github发布页,使用方式在readme(实在不知道咋用的话直接问我吧…)。之后打算好好维护,能解决自己和身边朋友的问题就已经达到最初的预期了。

如果你觉得这个项目还不错,欢迎给个star~ 点个关注鼓励一下也行。也可以关注b站:不想睡觉猫头猫或者公众号↓(刚整了个还没怎么发东西);不发水文,平时会更多分享一些自己做的好玩的小东西,学以致用才更有动力。谢谢你看到这里,下次想起来更新的时候再见~

httpweixin.q.png