基于Flutter的云音乐桌面版-音乐下载篇

先来说说背景吧。前段时间,我用Flutter断断续续的开发了一款桌面版的网易云音乐,成品还是比较满意的。已完成的功能包括推荐音乐、私人FM、我喜欢的音乐、我的收藏、歌曲评论、我的下载等。当然了,有关音乐的核心功能播放下载也都完成了。

前文引导👇:

基于Flutter开发的桌面版网易云音乐(一)

基于Flutter开发的桌面版网易云音乐(二)

一、MP3+JSON

问题就出在下载这里。当时对于音频文件下载的实现方式是通过MP3+JSON的方式实现的。有人就要问了,为什么要这么实现?同时下载一个JSON文件又是什么鬼?别急,且听我慢慢道来。

首先,我开发的DreamMusic项目播放音乐时使用的是外链,它长这样"[https://music.163.com/song/media/outer/url?id=$songId.mp3](https://music.163.com/song/media/outer/url?id=$songId.mp3)"。至于为何不用歌曲详情中的链接而直接使用外链,由于不是本文重点,我就不解释了。我们还是回到播放和下载上。通过这个音乐URL,我们可以播放在线音乐,当然也可以通过这个URL直接下载MP3文件。它是一个不包含任何媒体信息的原始音频文件(这里的媒体信息指的是歌名,歌手,专辑信息等)

现在我们来想一下,在做音乐下载模块时需要做什么?🤔

  1. 下载中进度展示。
  2. 下载完展示在列表上。
  3. 应用启动时能读取到音乐信息并展示出来。

%E6%88%AA%E5%B1%8F2022-12-07_15.52.13.png

其中1和2都是在运行中进行的,因此,音乐信息是能够直接从操作的音乐模型中获取到。那么,3应该如何实现。我上面有提到,从URL中下载的音频文件是一个原始的MP3文件,不包含其他任何媒体信息。如果要实现应用启动时展示已下载音乐列表,并能够展示出歌曲封面,歌名,歌手,专辑等信息,那么我们在下载的时候还需要同时存取一份音乐的媒体信息才行。因此才会有我第一版中提到的MP3+JSON方式。

%E6%88%AA%E5%B1%8F2022-12-07_15.50.31.png

上图中,那一串数字是云音乐平台的歌曲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下载目录,那么不多说,全部删除。

delete.gif

针对修改文件

很抱歉,我没做这个处理。因为我懒😄。其实是想出了更好的方式。那就是MP3+ID3的方式。

当然,我还是可以提供下思路。原理还是使用上述提到的监听文件修改的方式,这里我们监听修改JSON文件,一单文件的内容经过修改,系统会回调一个FileSystemModifyEvent对象给我们,里面有个属性叫contentChanged,我们判断下内容是否真的变了,变了就删掉,谁让你乱改下载文件的😄。当然最主要的原因是,文件系统没告诉我改了什么,变更前和变更后又是什么,实在不好判断呀~

二、MP3+ID3

所以,我就放弃继续在JSON上转牛角尖的想法,转而思考是否可以将媒体信息放入到音频文件内部。于是,顺理成章的了解到了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显示出来的。

%E6%88%AA%E5%B1%8F2022-12-07_16.48.19.png

这下,我们终于知道要将媒体信息存到哪里去了。那就是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_codecID3Decoder,当然还可以使用其他工具,这里我使用一款叫MediaInfo的工具,还看到了我们自定义存储的araldurationsongId信息。

%E6%88%AA%E5%B1%8F2022-12-07_17.01.05.png

%E6%88%AA%E5%B1%8F2022-12-07_17.04.26.png

其实剩下的就没有啥悬念了。我们通过id3_codecID3Decoder读取对应的信息,组装成模型展示出来即可。代码都在项目download_manager.dart_loadSongModelFromPath下,感兴趣的自行取查看。

总结

本文主要讲解了音乐下载中存储的方式和期间遇到的问题,以及最后的解决方法。也简单介绍了ID3,它的应用,以及相应的编解码库id3_codec。本文涉及到的项目地址👉点我查看DreamMusic👈,感谢支持。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容