基于开源网易云API+Flutter写的一款音乐播放器(二)

历时2个多月,基于开源网易云API的一款使用Flutter开发的桌面音乐程序终于完成了它的第一个版本v1.0.0🎉。再次感谢大佬为我们整理并提供众多接口。

效果图来一波。

效果图1.gif

本文接力上一篇介绍文,主要为大家讲解下开发DreamMusic中遇到的各种问题,以及关键实现。

用户登录

用户登录可以说是项目开发的第一步,你只有拿到cookie了,很多接口才能正常返回数据。我想,每一个基于网易云API开发项目的都有过下面的经历,调用登录接口 /login/cellphone(PS:密码登录和验证码登录)返回{code: -460, message: ‘网络太拥挤,请稍候再试!’}。GIthub上有人回答传入realIP可解。其实真实原因就是你没传cookie,或者传的cookie不对。只要在请求头上添加cookie即可

DreamMusic的登录态有三种,分别是:未登录,游客登录,用户登录。

大致流程是APP启动,读取本地保存的cookie,如果没有获取到,那么是第一次启动或是已经退出登录了,调用游客登录接口。如果存在cookie,但是没有MUSIC_U这个cookie,那么就认为当前是游客登录状态。如果是存在cookie,并且cookie中有MUSIC_U这个cookie的话,说明当前是用户登录状态,拉取用户信息接口能获取到具体的用户信息,如昵称,签名,手机号等。

[图片上传失败...(image-2f26d3-1667455027467)]

所以要实现上面的逻辑,关键在于cookie如何获取,从哪里获取。两个地方,一个是在每个接口响应头的set-cookie字段中获取,还有一个地方是在登录接口的响应体中获取,比如手机号登录返回的json中有个cookie字段记录了cookie的值。

[图片上传失败...(image-97b6af-1667455027467)]

这里推荐一个Flutter中好用的cookie解析和管理三方库,cookie_jardio_cookie_manager,前者主要是cookie的解析和io操作,后面的是dio的拦截器,可以在HTTP请求中注入和读取cookie,非常方便。

不过这里有个坑,就是如果你使用的是登录后响应体中的cookie,也就是返回json中的cookie的值,那么会存在解析异常的情况,也就是cookiejar会解析失败,并丢失部分cookie,具体原因可以自行查看两者的区别。因此我在项目中写了个辅助工具类CookieParse,先修正格式问题,再交给cookieJar处理。

做到上面这些,不出意外,登录应该是没问题了的。

音乐播放功能实现

音乐播放功能可以说是这个项目的核心了,当然由于Flutter是个UI框架,因此这部分功能还是需要依赖原生实现。市面上做的好的有关音乐播放的库其实也就两个,just_audioaudioplayers

[图片上传失败...(image-ec09e6-1667455027467)]

而要同时支持macoswindowslinux的就只有audioplayers了。所以没得选,就用audioplayers了。

当然在桌面端引入audioplayers,不出意外的报错了。

Warning: CocoaPods not installed. Skipping pod install.
  CocoaPods is used to retrieve the iOS and macOS platform side's plugin code that responds to your plugin usage on the Dart side.
  Without CocoaPods, plugins will not work on iOS or macOS.
  For more info, see https://flutter.dev/platform-plugins
To install see https://guides.cocoapods.org/using/getting-started.html#installation for instructions.

Exception: CocoaPods not installed or not in valid state.

这个问题看上去像是因为Cocoapods没有安装,导致无法给macos(iOS)平台安装正确的依赖环境,因此无法启动。事实上,cocoapods是安装了的,只要去掉audioplayers这个三方库,立马能正常跑起来。那么问题就出在这个三方库上,它的引入似乎导致了Flutter识别异常。

现在audioplayer的git仓库的issue上搜索了一番,没找到相关问题。看来这个问题比较特殊,只有自己遇到过。

我的解决办法,mac系统升级,xcode升级。应该有其他解决方法,我这边是正好遇上要升级系统,索性先升级系统了。

运行起来后,另一个问题出现了。

[ERROR:flutter/lib/ui/ui_dart_state.cc(198)] Unhandled Exception: AVPlayerItem.Status.failed
#0      MethodChannelAudioplayersPlatform._doHandlePlatformCall
package:audioplayers_platform_interface/method_channel_audioplayers_platform.dart:174
#1      MethodChannelAudioplayersPlatform.platformCallHandler
package:audioplayers_platform_interface/method_channel_audioplayers_platform.dart:147
#2      MethodChannel._handleAsMethodCall
package:flutter/…/services/platform_channel.dart:404
#3      MethodChannel.setMethodCallHandler.<anonymous closure>
package:flutter/…/services/platform_channel.dart:397
#4      _DefaultBinaryMessenger.setMessageHandler.<anonymous closure>
package:flutter/…/services/binding.dart:380
#5      _DefaultBinaryMessenger.setMessageHandler.<anonymous closure>
package:flutter/…/services/binding.dart:377
#6      _invoke2.<anonymous closure> (dart:ui/hooks.dart:190:15)
#7      _rootRun (dart:async/zone.dart:1426:13)
#8      _CustomZone.run (dart:async/zone.dart:1328:19)
#9      _CustomZone.runGuarded (dart:async/zone.dart:1236:7)
#10     _invoke2 (dart:ui/hooks.dart:189:10)
#11     _ChannelCallbackRecord.invoke (dart:ui/channel_buffers.dart:42:5)
#12     _Channel.push (dart:ui/channel_buffers.dart:132:31)
#13     ChannelBuffers.push (dart:ui/channel_buffers.dart:329:17)
#14     PlatformDispatcher._dispatchPlatformMessage (dart:ui/platform_dispatcher.dart:589:22)
#15     _dispatchPlatformMessage (dart:ui/hooks.dart:89:31)

ERROR出现的直接原因就是AVPlayerItem初始化失败,多为路径/url填错,如果是播放网络音乐,也可能是没有网络权限导致。

MacOS开发和iOS开发一样,如果要用一些系统级的功能,需要申请权限,在info.plist中添加配置信息。

如果要支持HTTPS,在macos目录下的info.plist中添加如下键值对。

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

如果是iOS到这一步就结束了,MacOS开发则要多做一步,向macos目录的DebugProfile.entitlements添加如下键值对。

<key>com.apple.security.network.client</key>
    <true/>

这时,音乐能正常播放了。

音乐播放的所有功能都封装在SongPlayer类下,目前实现的功能有:播放列表,播放,暂停,上一首,下一首,音量调节,播放跳转到指定时间点。

路由控制器

用过网易云音乐桌面端的都知道在应用的左上角有个两个按钮,用于控制页面前进和后退,以及Tab的切换,非常方便。因此这个实用的功能必须加上。

要实现这个功能,我们必须记录用户的每一次页面切换和tab切换。我将这个行为抽象为RouteAction,页面跳转是PageRouteAction,Tab切换是TabRouteAction。这些action都统一由RouteControlManager管理,这样就能在前进和后退时利用这些action来控制页面的切换了。

Tab切换也就是侧边栏的时机好处理,毕竟这个侧边栏是自定义的,按钮的点击完全可控。我们直接来看页面的切换也就是导航的push和pop操作。这个怎么监听?答案是使用自定义NavigatorObserver。NavigatorObserver中暴露了导航的每一次push和pop操作,以及相应的路由route对象,从中我们可以获取道对应的navigatorstate和routesetting,因此对于页面的切换也被我们掌控在了手中。

class CustomNavigatorObserver extends NavigatorObserver {
  // push: route是将要push的路由
    @override
  void didPush(Route route, Route? previousRoute) {
    super.didPush(route, previousRoute);
    RouteControlManager().pushAction(PageRouteAction(navigator: route.navigator, settings: route.settings));
  }

  // pop: route是将要被pop的路由
  @override
  void didPop(Route route, Route? previousRoute) {
    super.didPop(route, previousRoute);
    RouteControlManager().popAction(PageRouteAction(navigator: route.navigator, settings: route.settings));
  }
}

这里对这块没有深入的同学就要问了,为啥拿到avigatorStateroutesetting就可以控制页面的切换了。那我就多嘴一句。

我们先来看下Flutter中页面跳转是如何控制的。有两个页面A,B。从页面A到页面B叫push,再从页面B回到页面A叫pop。

// push
Navigator.pushNamed(context, "B");

// pop,返回导航栈的上一层
Navigator.pop(context);

这里的关键就是获取到上下文context,底层通过context查找到树结构中最近的NavigatorState,从而控制页面的导航。也就是context → NavigatorState。为什么要这样,因为我们的每个页面在导航中都是以OverlayEntry的形式存在并被管理的,拿到了NavigatorState也就代表拿到了页面的控制权。

回到自定义的NavigatorObserver中,这时,我们再进行Navigator.pushNamed(context, “setting”);时,就可以在CustomNavigatorObserver中拦截到跳转信息了。

flutter: [route]push --> ModelRoute(RouteSettings("setting", null), 
animation: AnimationController#68be1(▶ 0.000; for ModelRoute)), 
MaterialPageRoute<dynamic>(RouteSettings("/", null), 
animation: AnimationController#c6365(⏭ 1.000; paused; 
for MaterialPageRoute<dynamic>(/)))

CustomNavigatorObserver或者回调函数中的参数Route都能直接获取到NavigatorState,到这里,我们不通过context而直接获取到了控制导航的NavigatorState

现在,我们就可以写两个方法来控制前进和返回。

返回,调用navigatorpop()

void back() {
    if (canBack()) {
      final current = _actions[_currentIndex];
      _currentIndex -= 1;
      if (current.type == RouteActionType.page) {
        PageRouteAction action = current as PageRouteAction;
        action.navigator?.pop();
      }
      notifyListeners();
    }
  }

前进,调用navigatorpushNamed方法。

void forward() {
    if (canForward()) {
      _currentIndex += 1;
      final current = _actions[_currentIndex];
      if (current.type == RouteActionType.page) 
        PageRouteAction action = current as PageRouteAction;
        if (action.settings.name != null) {
          action.navigator?.pushNamed(action.settings.name!,
              arguments: action.settings.arguments);
        }
      } 
      notifyListeners();
    }
  }

这样,我们就可以搭配返回和前进按钮,通过调用back()forward()来全局控制页面的前进和后退了,完美。

歌词滚动和定位

知道每一行的高度,以及歌词列表的高度,可以通过context的findRenderObject来获取渲染组件的尺寸。

final ob = context.findRenderObject();
    if (ob != null && ob is RenderBox) {
      model?.size = ob.size;
    }

结合滚动监听NotificationListener<ScrollNotification>,获知当前的滚动偏移。计算出需要偏移的距离,调用ScrollControlleranimateTo即可实现滚动到指定行的歌词功能。

scrollController.animateTo(offsety,
            duration: const Duration(milliseconds: 200), curve: Curves.ease);

下载音乐

DreamMusic还实现了音乐下载功能。不过遇到了文件权限问题,我在下载歌曲尝试写入本地Download目录时,报以下错误:

出现异常。
FileSystemException (FileSystemException: Cannot create file, path = '/Users/zl/Library/Containers/com.jinfeng.dreammusic.dreamMusic/Data/Downloads' (OS Error: Operation not permitted, errno = 1))

这是因为我们没有读写文件的权限,需要去修改下用户的权限。

通过Apple的开发文档找到有关权限问题的说明。其中有个授权私钥的key为com.apple.security.files.downloads.read-write ,表示对用户的下载文件夹的读/写访问权限。那么,使用Xcode打开Flutter项目中的mac应用,修改工程目录下的DebugProfile.entitlements文件,向entitlements文件中添加com.apple.security.files.downloads.read-write,并将值设置为YES,保存后重启Flutter项目。发现已经可以向下载目录中读写文件了。

当然,这是正常操作。还有个骚操作就是关闭系统的沙盒机制。将entitlements文件的App Sandbox设置为NO。这样我们就可以访问任意路径了。当然关闭应用的沙盒也就相当于关闭了应用的防护机制,因此这个选项慎用。

下载歌曲后,歌曲的名字等信息如何保存?

这里有两个方案,一个是同时保存一个json文件,用于记录歌曲信息,这个方案的缺点是数据分散,内容容易丢失。另一个方案是直接存入mp3文件内部,网易云音乐的音乐下载是这么实现了,使用id3库可以读取到mp3文件中额外存储的信。ID3解析相关介绍

目前由于技术原因,暂时采用了下载mp3+json的方式。后面有时间会做优化。这种方式特别依赖于两个文件的完整性,毕竟mp3+json相当于一首完整的歌。因此DreamMusic还对下载文件做了删除的监听,通过下载方式实现。

final stream =
          directory.watch(events: FileSystemEvent.delete, recursive: true);
      stream.listen((event) async {
//处理删除后的逻辑
}

Show In Finder实现

当时看到这个功能,第一反应就是网上找文件操作相关的库。比较有名的有file_selectorfile_picker。这两个都提供了文件选择功能,但是没有单纯的打开文件目录的选项。然后我看了file_picker的实现,好家伙,竟然是直接通过命令行实现的,这当时就让我灵感一闪,有办法了。

open 命令 搞定一切。

class ShowInFinder {
  /// 打开目录
  static void open({String? initialDirectory}) {
    if (initialDirectory?.isNotEmpty == true) {
      final type = FileSystemEntity.typeSync(initialDirectory!);
      if (type == FileSystemEntityType.file || type == FileSystemEntityType.link) {
        debugPrint("[finder]$initialDirectory, 不是目录");
        return;
      }
      if (type == FileSystemEntityType.notFound) {
        // 指定目录不存在,创建
        Directory(initialDirectory).create();
      }
    }
    String argument = initialDirectory ?? '.';
    Process.run("open", [argument]);
  }
}

修改桌面应用的图标和名称

应用名称和图标都需要原生工程中去配置,这里也就是macos、windows、linux目录。

macOS配置看这里

windows配置看这里

Linux配置看这里

这里我只配了macos和windows的,毕竟谁会去用linux运行这个项目呢!(哈哈,其实就是懒)

最后

关于DreamMusic的实现细节就先讲这么多吧,如果有问题欢迎在评论区指出,感谢观看。最后再贴一波链接:DreamMusic

参考文档

Mac沙盒机制和权限配置

MP3文件结构解析(超详细)

MP3标签格式(ID3,APE)超详细解释

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容