基于Flutter的云音乐桌面版-音乐下载篇
先来说说背景吧。前段时间,我用Flutter
断断续续的开发了一款桌面版的网易云音乐,成品还是比较满意的。已完成的功能包括推荐音乐、私人FM、我喜欢的音乐、我的收藏、歌曲评论、我的下载等。当然了,有关音乐的核心功能播放下载也都完成了。
前文引导👇:
一、MP3+JSON
问题就出在下载这里。当时对于音频文件下载的实现方式是通过MP3+JSON
的方式实现的。有人就要问了,为什么要这么实现?同时下载一个JSON文件又是什么鬼?别急,且听我慢慢道来。
首先,我开发的DreamMusic项目播放音乐时使用的是外链,它长这样"https://music.163.com/song/media/outer/url?id=$songId.mp3"
。至于为何不用歌曲详情中的链接而直接使用外链,由于不是本文重点,我就不解释了。我们还是回到播放和下载上。通过这个音乐URL,我们可以播放在线音乐,当然也可以通过这个URL直接下载MP3文件。它是一个不包含任何媒体信息的原始音频文件(这里的媒体信息指的是歌名,歌手,专辑信息等)。
现在我们来想一下,在做音乐下载模块时需要做什么?🤔
- 下载中进度展示。
- 下载完展示在列表上。
- 应用启动时能读取到音乐信息并展示出来。
其中1和2都是在运行中进行的,因此,音乐信息是能够直接从操作的音乐模型中获取到。那么,3应该如何实现。我上面有提到,从URL中下载的音频文件是一个原始的MP3文件,不包含其他任何媒体信息。如果要实现应用启动时展示已下载音乐列表,并能够展示出歌曲封面,歌名,歌手,专辑等信息,那么我们在下载的时候还需要同时存取一份音乐的媒体信息才行。因此才会有我第一版中提到的MP3+JSON
方式。
上图中,那一串数字是云音乐平台的歌曲ID,其中的json文件就存储了歌曲的基本信息。用于在应用启动时加载歌曲信息。流程就是先找到JSON文件,读取JSON信息,转成已下载音乐的模型。写成代码就是下面这样。
String name =
directory.uri.pathSegments[directory.uri.pathSegments.length - 2];
final jsonFile = File("${directory.path}/$name.json");
final exist = await jsonFile.exists();
if (exist) {
final content = await jsonFile.readAsString();
final data = json.decode(content);
if (data is Map<String, dynamic>) {
final song = DownloadSongModel.fromJson(data);
return song;
}
} else {
debugPrint("[download]音乐[$name]json文件没有找到,删掉对应文件夹内容");
await directory.delete(recursive: true);
}
那么,这样写会有什么问题?🤔
一个显而易见的问题就是音频文件和媒体信息分离了,不便管理。这里可能有人就要说了,你这不是P话吗,媒体信息不分开放难道放MP3里?诶~还真可以,那就是使用ID3,这个我后面会提到。我们还是继续说MP3+JSON
这种方式。还有没有其他问题?有的,比如用户可以随意单独删除JSON或MP3,或打开JSON文件,修改其中的信息,导致音乐和媒体信息不一致。其中随意修改JSON信息真是致命的。
那么我在之前是如何处理上述问题的呢。我们接着往下看。
针对删除文件
场景如下,用户打开着应用,然后直接操作下载文件夹,单独删除了JSON,或MP3,或整个DreamMusic目录。如果我们要做同步,那就需要监听这些文件的变动。Flutter
文件系统为我们提供了这个方法:
Stream<FileSystemEvent> watch(
{int events = FileSystemEvent.all, bool recursive = false})
这个方法会监听文件的事件FileSystemEvent
,并通过回调的方式告诉我们。于是,我们可以很容易的写出下列代码,加入文件/文件夹删除监听。
/// 监听下载目录的变化,主要看文件有没有减少
void _addFileDeleteObserverIfNeeded() async {
if (!FileSystemEntity.isWatchSupported) {
return;
}
if (hasDirectoryObserver) {
return;
}
hasDirectoryObserver = true;
final directory = Directory(fileCacheDirectorPath);
if (!directory.existsSync()) {
await directory.create();
}
final stream =
directory.watch(events: FileSystemEvent.delete, recursive: true);
stream.listen((event) async {
// debugPrint("[download]$event");
String path = event.path;
if (path == fileCacheDirectorPath) {
// 删除了整个下载目录
_downloadedSongModels.clear();
hasDirectoryObserver = false;
debugPrint("[download]删除整个下载目录");
} else {
// 删除其中某个文件,这会导致信息不完整,因此直接全部删除即可
final lastSegment = Uri(path: path).pathSegments.last;
final fileName = lastSegment.split('.').first;
final songId = int.tryParse(fileName);
if (songId != null) {
final path = "$fileCacheDirectorPath/$songId";
final dir = Directory(path);
final exist = await dir.exists();
if (exist) {
await dir.delete(recursive: true);
}
final key = SongDownloadTask.createTaskId(songId);
_downloadedSongModels.remove(key);
}
debugPrint("[download]删除文件$lastSegment,songId-$songId");
}
notifyListeners();
});
debugPrint("[download]开始监听$fileCacheDirectorPath目录的变化");
}
逻辑处理很简单,如果用户单单删除了JSON或MP3,这就导致下载的音频文件不完整,于是,直接删除整个音乐文件夹就好**(这里指的是上面提到的那一串歌曲ID的文件夹,不是最外层的DreamMusic目录)**。如果用户是删除了整个DreamMusic下载目录,那么不多说,全部删除。
针对修改文件
很抱歉,我没做这个处理。因为我懒😄。其实是想出了更好的方式。那就是MP3+ID3
的方式。
当然,我还是可以提供下思路。原理还是使用上述提到的监听文件修改的方式,这里我们监听修改JSON文件,一单文件的内容经过修改,系统会回调一个FileSystemModifyEvent
对象给我们,里面有个属性叫contentChanged
,我们判断下内容是否真的变了,变了就删掉,谁让你乱改下载文件的😄。当然最主要的原因是,文件系统没告诉我改了什么,变更前和变更后又是什么,实在不好判断呀~
二、MP3+ID3
所以,我就放弃继续在JSON
上转牛角尖的想法,转而思考是否可以将媒体信息放入到音频文件内部。于是,顺理成章的了解到了ID3
(真的是问题不可怕,它是前进的动力)。
ID3维基百科。不了解ID3
的可以先看看这是何物,有何作用。简单来说,ID3就是存在于音频文件中用于存放媒体信息的一段内容。它有自己的格式,目前流行的是ID3v2.3
和ID3v2.4
版本。
了解了ID3
基本的信息后,我就去找对应的三方库呀,看看有没有现成的可以帮助我解决问题的id3解析库存在。很快的,我就找到了一个排名靠前的ID3库实现,id3。然后,我又分别实验了下自己下载的mp3文件和Mac版网易云音乐下载的mp3文件,里面都有些什么。发现果然有些东西。
下面是网易云下载的歌曲读取出来的ID3信息:
{
Version: v2.3.0,
Settings: Lavf57.25.100,
TPOS: 1,
Track: 12, Artist: 大壮,
APIC: {mime: image/jpg, textEncoding: 0, picType: Other, description: , base64: iVBORw0K...},
Title: 为你我受冷风吹,
Album: 大壮首张限量定制翻唱
}
而我自己下载的歌曲文件的ID3中没有任何媒体信息:
{
Version: v2.3.0,
Settings: Lavf57.71.100
}
其实,在Mac上,我们平时快捷预览MP3文件时也会出现一些媒体信息,而这些信息就是mac桌面系统通过读取ID3
显示出来的。
这下,我们终于知道要将媒体信息存到哪里去了。那就是ID3
中。可问题来了,id3
这个三方库它不支持编辑啊。先不说它有没有bug,它不支持编辑啊。
于是,我查看了ID3有关v1,v2的所有版本的官方信息。又在网上看了不少前辈的文章讲解,心里有了明悟,我为什么不自己写呢?
于是前后经历一个月时间,一个支持ID3
解码和编码的id3_codec终于实现了🎉。并且还支持ID3所有版本(编码这块v2.2不做支持,因为基本没人用)。
有关id3_codec实现可以看我下列文章:
有了ID3
的支持,我们就可以将媒体信息存入MP3中了,这样就解决了上面所有的问题。再也不担心用户乱改了(当然,如果有用户用编码器修改ID3信息,那我服了)。
我们只需要将写入JSON的逻辑改成写入MP3原文件即可。看代码:
/// 将歌曲信息写入
void _writeSongInfoAsync(DownloadSongModel song) async {
if (_cacheMode == DownloadCacheMode.json) {
// 略
} else if (_cacheMode == DownloadCacheMode.id3) {
final path = _generateSongId3SavePath(song.name);
final file = File(path);
bool exist = await file.exists();
if (exist) {
final bytes = await file.readAsBytes();
final encoder = ID3Encoder(bytes);
final al = json.encode(song.al.toJson());
final resultBytes = encoder.encodeSync(MetadataV2_3Body(
title: song.name,
artist: song.authorNmae,
album: song.al.name,
userDefines: {
"duration": song.time.toString(),
"songId": song.songId.toString(),
"ar": json.encode(song.ar.map((e) => e.toJson()).toList()),
"al": al,
}));
file.writeAsBytes(resultBytes, mode: FileMode.write);
debugPrint("[download]finish encode id3 info: ${song.name}");
}
}
}
我下载了一首歌“是你”,桌面预览能直接看到歌曲标题等信息。如果要看详细点,我们可以直接通过id3_codec的ID3Decoder
,当然还可以使用其他工具,这里我使用一款叫MediaInfo的工具,还看到了我们自定义存储的ar
、al
、duration
和songId
信息。
其实剩下的就没有啥悬念了。我们通过id3_codec的ID3Decoder
读取对应的信息,组装成模型展示出来即可。代码都在项目的download_manager.dart
的_loadSongModelFromPath
下,感兴趣的自行取查看。
总结
本文主要讲解了音乐下载中存储的方式和期间遇到的问题,以及最后的解决方法。也简单介绍了ID3
,它的应用,以及相应的编解码库id3_codec。本文涉及到的项目地址👉点我查看DreamMusic👈,感谢支持。
转载自:https://juejin.cn/post/7174334108657516557