Flutter写个Nas音乐播放器(二)
呐喊!想要通过路由做局部刷新的,千万要自己写路由,不要用flutter自带的,会让你感受到崩溃。今天为了右侧部分的局部刷新切到全局路由,改了大部分代码之后发现,根本不能在保留左侧和底部Widget的前提下动态刷新,一气之下重新写了一个终于好用了,但是前后浪费了好久。没办法,为了到时候调移动端的时候,左边可以做为侧滑窗体独立出来,决定用ValueNotifier硬写路由,然后把左侧的当前页高亮显示去掉了,主要有Detail页面,虽然可以跟踪到上级,不过感觉要不要无所谓。我写了一个硕大的40号字体标题放在那,怎么也不会看不到
目前已经算是可以使用了,歌手和专辑以及播放的全部逻辑都做完了,后面还有就是首页,无非就是查询点正在播放之类的方便使用。还有两个重头,搜索和歌词,不知道直接转码能不能实现繁简通查,不行的话还要用API,就浪费查询时间

1. 专辑瀑布流
这个是我一直想要的,自从用了Jellyfin之后,感觉以前电影都白看了,这种电视幕墙是比较帅的,瀑布流有现成的轮子可以用flutter_staggered_grid_view实现起来非常简单,在pubspec.yaml里面添加然后flutter pub get即可
它本身支持多种呈现方式详细可以去主页看,不过我选了最合适大量图片的MasonryGridView,和构建ListView一样的,这里有三个属性需要定义
crossAxisCount: 多少列
mainAxisSpacing: 行间距
crossAxisSpacing: 列间距
我是建议做一个计算,因为你固定Count的话会导致一个问题,窗体拖大会让图片特别大,就很怪
1//取窗体大小
2Size _size = MediaQuery.of(context).size;
3//右侧是我要展示的部分,先把这个宽度算出来,然后除以想要呈现的图片宽度
4double _rightWidth = (_size.width - 160) / 180;
5//然后向下取整
6int _count = _rightWidth.truncate();
这样一来你怎么拖动窗体这个图片宽度只会在小范围内变化,不会特别夸张,构建也很简单
1MasonryGridView.count(
2 crossAxisCount: _count,
3 mainAxisSpacing: 10,
4 crossAxisSpacing: 10,
5 itemCount: _albums!.length,
6 itemBuilder: (context, index) {
7 return ...
8 }
里面可以封一个Widget的组合,比如图片加两行文字这样,效果就出来了
当然啦,可以做图片缓存,我本来也是想要做的,只是没想好到底是保存在文件夹里还是转base64存到数据库里面
其实不做缓存也可以,图片懒加载的效果还是可以的,用frameBuilder套一下可以实现渐显效果
1ClipRRect(
2 borderRadius: BorderRadius.circular(15),
3 child: Image.network(
4 _temURL,
5 fit: BoxFit.cover,
6 frameBuilder: (context, child, frame,wasSynchronouslyLoaded) {
7 if (wasSynchronouslyLoaded) {
8 return child;
9 }
10 return AnimatedSwitcher(
11 child: frame != null
12 ? child
13 : Image.asset("assets/images/logo.jpg"),
14 duration: const Duration(milliseconds: 2000),
15 );
16 },
17 ),
18)
2. 音乐播放
播放一首歌很简单,直接setAudioSource就可以了,麻烦的是播放列表,以及为了控制播放列表的按钮操作。比如前一首没有了,但是按钮没有置灰点了就会报错之类的,所有的控制都使用ValueNotifier来进行,我算是把它用明白了
2.1. 设置监控变量
1//监听当前资源ID,这个不单用于音乐播放,也用于获取歌手图片啥的
2ValueNotifier<String> activeSongValue = ValueNotifier<String>("1");
3
4//监听当前播放列表
5ValueNotifier<List> activeList = ValueNotifier<List>([]);
6
7//监听当前歌曲序列
8ValueNotifier<int> activeIndex = ValueNotifier<int>(0);
9
10//监听当前歌曲,和上面的类似,传过去只是为了省一次数据库查询,因为要填歌曲名、歌手名和专辑名,不然这个不用传
11ValueNotifier<Map> activeSong = ValueNotifier<Map>(Map());
12
13//监听是否乱序,这个是监听那个乱序按钮做触发用的
14ValueNotifier<bool> isShuffleModeEnabledNotifier = ValueNotifier<bool>(false);
15
16//监听是否是第一首歌
17ValueNotifier<bool> isFirstSongNotifier = ValueNotifier<bool>(true);
18
19//监听是否是最后一首歌,这都是为了封掉上一首下一首按钮避免报错用的
20ValueNotifier<bool> isLastSongNotifier = ValueNotifier<bool>(true);
2.2. 触发
在歌曲列表里面点击歌曲之后,把id、歌曲List和当前歌曲在列表中的index传过去,然后封一个Map
主要是为了拿歌曲的图片URL,这个在原本的Map里面没有,需要到后台拼一个带服务器地址和身份信息的URL出来,不然直接用Songs的实例更加方便
1onTap: () async {
2 activeSongValue.value = _tem.id;
3 //歌曲所在专辑歌曲List
4 activeList.value = _songs!;
5 //当前歌曲队列
6 activeIndex.value = index;
7 //拼装当前歌曲
8 Map _activeSong = new Map();
9 String _url = await getCoverArt(_tem.id);
10 _activeSong["artist"] = _tem.artist;
11 _activeSong["url"] = _url;
12 _activeSong["title"] = _tem.title;
13 _activeSong["album"] = _tem.album;
14 activeSong.value = _activeSong;
15}
2.3. 接收和生成播放列表
这里接收的是activeSongValue,为什么不直接用activeList呢,因为如果你接收activeList的话,在当前列表内点其他的歌,它默认值没有发生变化
1ValueListenableBuilder<String>(
2 valueListenable: activeSongValue,
3 builder: ((context, value, child) {
4 if (value != "1") {
5 setAudioSource();
6 }
7 return ...
如果是默认值1就说明你还没选择音乐
值发生变化就开始生成播放列表
1Future<void> setAudioSource() async {
2 //1. 声明
3 final playlist = ConcatenatingAudioSource(
4 useLazyPreparation: true,
5 shuffleOrder: DefaultShuffleOrder(),
6 children: await _getPlayList(),
7 );
8 //3. 添加播放列表,顺便设置当前播放第几首歌
9 await _player.setAudioSource(playlist,
10 initialIndex: activeIndex.value, initialPosition: Duration.zero);
11 //4. 开始播放
12 _player.play();
13}
14
15 _getPlayList() async {
16 List<AudioSource> children = [];
17 List _songs = activeList.value;
18 把触发的时候传的值拿出来
19 for (var element in _songs) {
20 Songs _song = element;
21 //2. 去数据库拼音乐文件的地址
22 String _url = await getSongStreamUrl(_song.id);
23 children.add(AudioSource.uri(Uri.parse(_url), tag: _song.id)); //这里注意设置tag,后面的监听用得到
24 }
25 return children;
26 }
2.4. 监听播放变化
当播放到下一首的时候,需要把歌曲名字封面这些信息换掉,这时候就需要注入监听来进行操作
Just Audio 有许多不同的事件流,都可以监听:
- sequenceStream:这是添加的顺序的播放列表,每当播放列表更改时,这里将产生一个新列表
- shuffleModeEnabledStream:乱序播放的监听,打开和关闭此模式,是个布尔值
- shuffleIndicesStream:这是一个打乱的整数列表,指向播放列表中的项目
sequenceStream(它本身不会打乱)。shuffle当音频播放器上调用该方法时,这里将产生一个新列表。 - currentIndexStream:此流会在歌曲首次开始时通知当前歌曲的索引。这个整数指向播放列表中的一个音频源
sequenceStream - loopModeStream:默认播放列表中的所有歌曲一次。但是,也可以选择重复单首歌曲甚至重复播放列表,就是循环模式,
LoopMode每次循环模式更改时,此流都会产生一个新的值
好了,上面的看看就行了,监听嘛,用一个就能搞定,就是sequenceStateStream,这里只要上面的五个流之一产生一个新值,都会sequenceStateStream产生一个组合值 type SequenceState,一个顶五个,美滋滋
首先在initState里面加入这个方法调用,然后..
1 void _listenForChangesInSequenceState() {
2 //当
3 //播到下一首的时候触发
4 _player.sequenceStateStream.listen((sequenceState) async {
5 //没东西的时候滚粗
6 if (sequenceState == null) return;
7
8 // 更新当前播放歌曲的信息
9 final currentItem = sequenceState.currentSource;
10 //拿到生成播放列表的时候放到tag里面的id
11 final _title = currentItem?.tag as String?;
12 //去数据库查资料拿到歌曲信息
13 final _tem = await getSong(_title.toString());
14 //更新当前歌曲id
15 activeSongValue.value = _title.toString();
16
17 //拼装当前歌曲
18 Map _activeSong = new Map();
19 String _url = await getCoverArt(_tem["id"]);
20 _activeSong["artist"] = _tem["artist"];
21 _activeSong["url"] = _url;
22 _activeSong["title"] = _tem["title"];
23 _activeSong["album"] = _tem["album"];
24 activeSong.value = _activeSong;
25
26 // 这里监控乱序的,我没用,自己写了一个(主要是先写了乱序的逻辑,后做的播放列表,懒得改了)
27 // isShuffleModeEnabledNotifier.value = sequenceState.shuffleModeEnabled;
28
29 //拿到播放列表
30 final playlist = sequenceState.effectiveSequence;
31 //判断上一首下一首,然后把值传进去给按钮做状态判断
32 if (playlist.isEmpty || currentItem == null) {
33 isFirstSongNotifier.value = true;
34 isLastSongNotifier.value = true;
35 } else {
36 isFirstSongNotifier.value = playlist.first == currentItem;
37 isLastSongNotifier.value = playlist.last == currentItem;
38 }
39 });
40 }
2.5. 快进快退
其实这俩按钮现在很多人都已经取消掉了,因为有进度条啊,那个多方便,不过之前把按钮都写好了,那就写呗,删了多可惜
1 IconButton(
2 icon: const Icon(
3 Icons.fast_rewind,
4 color: kTextColor,
5 ),
6 onPressed: () {
7 if (widget.player.position.inSeconds - 15 > 0) {
8 widget.player.seek(
9 Duration(seconds: widget.player.position.inSeconds - 15));
10 }
11 },
12 ),
这里需要做一个判断,如果你当前的位置减你想要退会的位置小于0,就不要操作,避免报错,前进倒无所谓,加多了直接转下一首了
2.6. 上下首
这里就需要监听前面设置的值了isFirstSongNotifier和isLastSongNotifier,不做过滤的话,当在播放第一首的时候点上一首,和在最后一首的时候点下一首都会有问题
1ValueListenableBuilder<bool>(
2 valueListenable: isLastSongNotifier,
3 builder: (_, isLast, __) {
4 return IconButton(
5 icon: Icon(
6 Icons.skip_next,
7 color: isLast ? badgeDark : kTextColor,
8 ),
9 onPressed: () {
10 (isLast) ? null : widget.player.seekToNext();
11 },
12 );
13 }),
做一个判断,如果有问题的话直接把按钮置灰,然后onPressed传一个null就可以了
2.7. 单曲循环、全部循环、乱序播放、停止、暂停
难点在上面,全部都写好之后,按钮部分就比较简单
单曲循环:player.setLoopMode(LoopMode.one);
全部循环:player.setLoopMode(LoopMode.all);
乱序播放: player.setShuffleModeEnabled(true);
3. 等宽字体
由于在歌曲进度条前后加了数字,所以一个老生常谈的问题出现了,就是数字从1到0的时候会导致后面的进度条有位移,所以这里的数字需要用等宽字体。
整个APP我用了两个字体,一个是NotoSansSC,这是支持中日韩英的字体,用起来贼happy,是从我当时设置的Jellyfin的备用字体里面拖过来的。当时也是为了显示中日韩英的字幕用的,这里一样可以用。
不过这个字体有个问题,它有两个版本,正常的版本数字和英文都显示没问题的,但是Mono的版本,就是等宽字体会把英文和数字用半角显示,就会变细,看起来头很大,所以我又从Google Fonts里面找了一个小的等宽字体来用,专门用于进度条前面的数字,避免位移
声明Asset
还是在pubspec.yaml里面声明
1 # To add assets to your application, add an assets section, like this:
2 assets:
3 - assets/images/
4 fonts:
5 - family: NotoSansSC
6 fonts:
7 - asset: assets/fonts/NotoSansSC-Regular.otf
8 - family: ChivoMono
9 fonts:
10 - asset: assets/fonts/ChivoMono-Regular.ttf
- 局部使用
1 Text(
2 formatSongDuration(widget.position),
3 style: TextStyle(
4 color: borderColor, fontFamily: 'ChivoMono', fontSize: 12),
5 )
- 全局使用
1return MaterialApp(
2 debugShowCheckedModeBanner: false,
3 title: 'XiuMusic',
4 theme: ThemeData(fontFamily: 'NotoSansSC'),
5 home: MainScreen(),
6 );
4. Todo List
这东西已经玩了一周了,压力大的时候写写代码真心解压,所以我也不着急完成它,慢慢来吧
Todo List直接在Github上更新了
其实目前已经可以用了,有兴趣的人可以下来玩一玩,XiuMusic

