Flutter写个Nas音乐播放器(二)
2023-01-11 02:36 ≈ 2.8k字 ≈ 11分钟

呐喊!想要通过路由做局部刷新的,千万要自己写路由,不要用flutter自带的,会让你感受到崩溃。今天为了右侧部分的局部刷新切到全局路由,改了大部分代码之后发现,根本不能在保留左侧和底部Widget的前提下动态刷新,一气之下重新写了一个终于好用了,但是前后浪费了好久。没办法,为了到时候调移动端的时候,左边可以做为侧滑窗体独立出来,决定用ValueNotifier硬写路由,然后把左侧的当前页高亮显示去掉了,主要有Detail页面,虽然可以跟踪到上级,不过感觉要不要无所谓。我写了一个硕大的40号字体标题放在那,怎么也不会看不到

目前已经算是可以使用了,歌手和专辑以及播放的全部逻辑都做完了,后面还有就是首页,无非就是查询点正在播放之类的方便使用。还有两个重头,搜索和歌词,不知道直接转码能不能实现繁简通查,不行的话还要用API,就浪费查询时间

1. 专辑瀑布流

这个是我一直想要的,自从用了Jellyfin之后,感觉以前电影都白看了,这种电视幕墙是比较帅的,瀑布流有现成的轮子可以用flutter_staggered_grid_view实现起来非常简单,在pubspec.yaml里面添加然后flutter pub get即可

它本身支持多种呈现方式详细可以去主页看,不过我选了最合适大量图片的MasonryGridView,和构建ListView一样的,这里有三个属性需要定义

crossAxisCount: 多少列

mainAxisSpacing: 行间距

crossAxisSpacing: 列间距

我是建议做一个计算,因为你固定Count的话会导致一个问题,窗体拖大会让图片特别大,就很怪

1
2
3
4
5
6
//取窗体大小
Size _size = MediaQuery.of(context).size;
//右侧是我要展示的部分,先把这个宽度算出来,然后除以想要呈现的图片宽度
double _rightWidth = (_size.width - 160) / 180;
//然后向下取整
int _count = _rightWidth.truncate();

这样一来你怎么拖动窗体这个图片宽度只会在小范围内变化,不会特别夸张,构建也很简单

1
2
3
4
5
6
7
8
MasonryGridView.count(
crossAxisCount: _count,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
itemCount: _albums!.length,
itemBuilder: (context, index) {
return ...
}

里面可以封一个Widget的组合,比如图片加两行文字这样,效果就出来了

当然啦,可以做图片缓存,我本来也是想要做的,只是没想好到底是保存在文件夹里还是转base64存到数据库里面

其实不做缓存也可以,图片懒加载的效果还是可以的,用frameBuilder套一下可以实现渐显效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClipRRect(
borderRadius: BorderRadius.circular(15),
child: Image.network(
_temURL,
fit: BoxFit.cover,
frameBuilder: (context, child, frame,wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) {
return child;
}
return AnimatedSwitcher(
child: frame != null
? child
: Image.asset("assets/images/logo.jpg"),
duration: const Duration(milliseconds: 2000),
);
},
),
)

2. 音乐播放

播放一首歌很简单,直接setAudioSource就可以了,麻烦的是播放列表,以及为了控制播放列表的按钮操作。比如前一首没有了,但是按钮没有置灰点了就会报错之类的,所有的控制都使用ValueNotifier来进行,我算是把它用明白了

2.1. 设置监控变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//监听当前资源ID,这个不单用于音乐播放,也用于获取歌手图片啥的
ValueNotifier<String> activeSongValue = ValueNotifier<String>("1");

//监听当前播放列表
ValueNotifier<List> activeList = ValueNotifier<List>([]);

//监听当前歌曲序列
ValueNotifier<int> activeIndex = ValueNotifier<int>(0);

//监听当前歌曲,和上面的类似,传过去只是为了省一次数据库查询,因为要填歌曲名、歌手名和专辑名,不然这个不用传
ValueNotifier<Map> activeSong = ValueNotifier<Map>(Map());

//监听是否乱序,这个是监听那个乱序按钮做触发用的
ValueNotifier<bool> isShuffleModeEnabledNotifier = ValueNotifier<bool>(false);

//监听是否是第一首歌
ValueNotifier<bool> isFirstSongNotifier = ValueNotifier<bool>(true);

//监听是否是最后一首歌,这都是为了封掉上一首下一首按钮避免报错用的
ValueNotifier<bool> isLastSongNotifier = ValueNotifier<bool>(true);

2.2. 触发

在歌曲列表里面点击歌曲之后,把id、歌曲List和当前歌曲在列表中的index传过去,然后封一个Map

主要是为了拿歌曲的图片URL,这个在原本的Map里面没有,需要到后台拼一个带服务器地址和身份信息的URL出来,不然直接用Songs的实例更加方便

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
onTap: () async {
activeSongValue.value = _tem.id;
//歌曲所在专辑歌曲List
activeList.value = _songs!;
//当前歌曲队列
activeIndex.value = index;
//拼装当前歌曲
Map _activeSong = new Map();
String _url = await getCoverArt(_tem.id);
_activeSong["artist"] = _tem.artist;
_activeSong["url"] = _url;
_activeSong["title"] = _tem.title;
_activeSong["album"] = _tem.album;
activeSong.value = _activeSong;
}

2.3. 接收和生成播放列表

这里接收的是activeSongValue,为什么不直接用activeList呢,因为如果你接收activeList的话,在当前列表内点其他的歌,它默认值没有发生变化

1
2
3
4
5
6
7
ValueListenableBuilder<String>(
valueListenable: activeSongValue,
builder: ((context, value, child) {
if (value != "1") {
setAudioSource();
}
return ...

如果是默认值1就说明你还没选择音乐

值发生变化就开始生成播放列表

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
Future<void> setAudioSource() async {
//1. 声明
final playlist = ConcatenatingAudioSource(
useLazyPreparation: true,
shuffleOrder: DefaultShuffleOrder(),
children: await _getPlayList(),
);
//3. 添加播放列表,顺便设置当前播放第几首歌
await _player.setAudioSource(playlist,
initialIndex: activeIndex.value, initialPosition: Duration.zero);
//4. 开始播放
_player.play();
}

_getPlayList() async {
List<AudioSource> children = [];
List _songs = activeList.value;
把触发的时候传的值拿出来
for (var element in _songs) {
Songs _song = element;
//2. 去数据库拼音乐文件的地址
String _url = await getSongStreamUrl(_song.id);
children.add(AudioSource.uri(Uri.parse(_url), tag: _song.id)); //这里注意设置tag,后面的监听用得到
}
return children;
}

2.4. 监听播放变化

当播放到下一首的时候,需要把歌曲名字封面这些信息换掉,这时候就需要注入监听来进行操作

Just Audio 有许多不同的事件流,都可以监听:

  • sequenceStream:这是添加的顺序的播放列表,每当播放列表更改时,这里将产生一个新列表
  • shuffleModeEnabledStream:乱序播放的监听,打开和关闭此模式,是个布尔值
  • shuffleIndicesStream:这是一个打乱的整数列表,指向播放列表中的项目sequenceStream(它本身不会打乱)。shuffle当音频播放器上调用该方法时,这里将产生一个新列表。
  • currentIndexStream:此流会在歌曲首次开始时通知当前歌曲的索引。这个整数指向播放列表中的一个音频源sequenceStream
  • loopModeStream:默认播放列表中的所有歌曲一次。但是,也可以选择重复单首歌曲甚至重复播放列表,就是循环模式,LoopMode每次循环模式更改时,此流都会产生一个新的值

好了,上面的看看就行了,监听嘛,用一个就能搞定,就是sequenceStateStream,这里只要上面的五个流之一产生一个新值,都会sequenceStateStream产生一个组合值 type SequenceState,一个顶五个,美滋滋

首先在initState里面加入这个方法调用,然后..

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
void _listenForChangesInSequenceState() {
//当
//播到下一首的时候触发
_player.sequenceStateStream.listen((sequenceState) async {
//没东西的时候滚粗
if (sequenceState == null) return;

// 更新当前播放歌曲的信息
final currentItem = sequenceState.currentSource;
//拿到生成播放列表的时候放到tag里面的id
final _title = currentItem?.tag as String?;
//去数据库查资料拿到歌曲信息
final _tem = await getSong(_title.toString());
//更新当前歌曲id
activeSongValue.value = _title.toString();

//拼装当前歌曲
Map _activeSong = new Map();
String _url = await getCoverArt(_tem["id"]);
_activeSong["artist"] = _tem["artist"];
_activeSong["url"] = _url;
_activeSong["title"] = _tem["title"];
_activeSong["album"] = _tem["album"];
activeSong.value = _activeSong;

// 这里监控乱序的,我没用,自己写了一个(主要是先写了乱序的逻辑,后做的播放列表,懒得改了)
// isShuffleModeEnabledNotifier.value = sequenceState.shuffleModeEnabled;

//拿到播放列表
final playlist = sequenceState.effectiveSequence;
//判断上一首下一首,然后把值传进去给按钮做状态判断
if (playlist.isEmpty || currentItem == null) {
isFirstSongNotifier.value = true;
isLastSongNotifier.value = true;
} else {
isFirstSongNotifier.value = playlist.first == currentItem;
isLastSongNotifier.value = playlist.last == currentItem;
}
});
}

2.5. 快进快退

其实这俩按钮现在很多人都已经取消掉了,因为有进度条啊,那个多方便,不过之前把按钮都写好了,那就写呗,删了多可惜

1
2
3
4
5
6
7
8
9
10
11
12
IconButton(
icon: const Icon(
Icons.fast_rewind,
color: kTextColor,
),
onPressed: () {
if (widget.player.position.inSeconds - 15 > 0) {
widget.player.seek(
Duration(seconds: widget.player.position.inSeconds - 15));
}
},
),

这里需要做一个判断,如果你当前的位置减你想要退会的位置小于0,就不要操作,避免报错,前进倒无所谓,加多了直接转下一首了

2.6. 上下首

这里就需要监听前面设置的值了isFirstSongNotifierisLastSongNotifier,不做过滤的话,当在播放第一首的时候点上一首,和在最后一首的时候点下一首都会有问题

1
2
3
4
5
6
7
8
9
10
11
12
13
ValueListenableBuilder<bool>(
valueListenable: isLastSongNotifier,
builder: (_, isLast, __) {
return IconButton(
icon: Icon(
Icons.skip_next,
color: isLast ? badgeDark : kTextColor,
),
onPressed: () {
(isLast) ? null : widget.player.seekToNext();
},
);
}),

做一个判断,如果有问题的话直接把按钮置灰,然后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里面找了一个小的等宽字体来用,专门用于进度条前面的数字,避免位移

  1. 声明Asset

    还是在pubspec.yaml里面声明

1
2
3
4
5
6
7
8
9
10
# To add assets to your application, add an assets section, like this:
assets:
- assets/images/
fonts:
- family: NotoSansSC
fonts:
- asset: assets/fonts/NotoSansSC-Regular.otf
- family: ChivoMono
fonts:
- asset: assets/fonts/ChivoMono-Regular.ttf
  1. 局部使用
1
2
3
4
5
Text(
formatSongDuration(widget.position),
style: TextStyle(
color: borderColor, fontFamily: 'ChivoMono', fontSize: 12),
)
  1. 全局使用
1
2
3
4
5
6
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'XiuMusic',
theme: ThemeData(fontFamily: 'NotoSansSC'),
home: MainScreen(),
);

4. Todo List

这东西已经玩了一周了,压力大的时候写写代码真心解压,所以我也不着急完成它,慢慢来吧

Todo List直接在Github上更新了

其实目前已经可以用了,有兴趣的人可以下来玩一玩,XiuMusic