Flutter写个Nas音乐播放器(四)
2023-01-14 16:50 2023-01-15 15:54 ≈ 2.9k字 ≈ 11分钟

今天把拖了好久的首页写完了,包括了三个:随机专辑、最多播放歌曲列表以及最近添加专辑

其实这个APP和网易云最大的不同就是这些歌都是你自己收集起来听的,所以那些推荐的算法都不需要了

CustomScrollView

首页最麻烦的是横向列表和纵向列表混排,不过也不是很复杂,主要是一定记得给固定高度和宽度,或者是最大宽度最大高度什么的,不然非常容易报错

首先就是让人又爱又恨的Sliver,自带的动画省却了好多事情,不过为了这些自动化的功能,导致只有少部分控件支持当他的child。但是好在官方给了个SliverToBoxAdapter组件,让不是他child的组件可以套层皮,这就很nice

布局嘛,就是一个横向滚动的10张随机专辑,下面是一个10首最多播放的纵向歌曲列表,再下面是最近添加的横向的10张专辑,可能把最多播放歌曲改成5个更好看一点?

WechatIMG650

实现也比较简单,除了亲儿子SliverList控件之外,比如标题部分要用SliverToBoxAdapter套一下就可以了,直接看代码吧

因为我要兼容桌面端,桌面除了苹果的魔术鼠标可以左右滑动,一般的鼠标是没有这个功能的,所以需要加一个左右滑动的按钮,给桌面端来点击

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
return CustomScrollView(
slivers: <Widget>[
//这个是套首页文本的
SliverToBoxAdapter(
child: Container(
padding: leftrightPadding,
child: Text(indexLocal, style: titleText1)),
),
if (_albums != null && _albums!.length > 0)
SliverToBoxAdapter(
//这个套横向的标题栏,包括文本和左右按钮,我写了个公共文件方便套用
child: MySliverControlBar(
title: "随机专辑",
controller: _randomAlbumcontroller,
)),
if (_albums != null && _albums!.length > 0)
SliverToBoxAdapter(
//这个套横向的滚动图片,我写了个公共文件方便套用
child: MySliverControlList(
controller: _randomAlbumcontroller,
albums: _albums!,
url: _imageURL!,
))
]
);

这是首页build里面的内容,然后MySliverControlBar里面就很简单,就是一个Row套一个Row这样布局

MySliverControlList里面其实就是一个横向的ListView

这里注意如果没有桌面端的需求,就是你不用手动控制滚动的话,是不需要写控制器的,但是我需要控制滚动,就要注入一个控制器,控制方法比较简单,点击按钮触发即可

1
2
3
controller.animateTo(controller.offset - _size.width / 2,
duration: Duration(milliseconds: 200),
curve: Curves.ease);

offset就是位移,大气点,点一次直接移动半屏,省的点半天都不带动的

这样首页就做完了,后面想往上面再加什么内容,那都是分分钟的,目前就这样吧。

另外今天把布局又做了一次调整,把下面的控制部分彻底拿到bottomNavigationBar里面去了,这样布局看起来简单的多了,后面可能还要再做一次调整就是中间的部分,准备直接把LeftScreen嵌到每个页面里面去,这样我就可以使用flutter整体路由了,可以去掉一个控制路由的全局变量

最关键的是可以再做个判断,如果是桌面端就显示LeftScreen,如果是移动端就在bottomNavigationBar里面直接加导航,这样移动端顶部左侧的那个侧滑的按钮就可以去掉了。左侧空出来可以给Windows端留个位置,当客户端是Windows的时候,那个搜索和设置按钮可以贴到左边去,因为Windows的放大缩小和叉叉是在右边的,我为了好看设置了去掉标题栏,所以不能挡住叉叉

使用MediaQuery导致弹不出软键盘

移动端解决

这是多端开发的时候遇到的问题,因为Scaffold会根据不同设备去适配不同的窗体大小,而动态的取窗口大小的方法就是MediaQuery.of(context).size,这在桌面端开发的时候十分方便,但是在手机端遇到键盘弹出的时候就会出现问题

如果你的panel高度用了size做强制定义,但是软键盘弹出其实窗体上滑的高度也是用这个计算的,所以上滑后你的panel又把键盘的位置顶下去了,就会导致软键盘频繁闪烁不会保持弹出的状态,而且页面不停的刷新

如果想要做强制定位最好的方法还是使用window.physicalSize,,因为移动端的窗体大小是不会有变化的,然后为了做多端适应,最终使用的宽度应该是:window.physicalSize.width / window.devicePixelRatio,同时在Scaffold里面加上resizeToAvoidBottomInset: false避免微小位移导致的报错

桌面端解决

移动端设置好之后,桌面端会存在这样一个问题,window.physicalSize是调用一次的。但是,桌面端是可以拖动放大缩小的,如果我们在桌面端继续使用window.physicalSize就会导致放大缩小的时候panel还是原来定好的高度,这时候我们又希望它动态做出调整,有两个方法可以做到,一个是添加一个窗体的监听器,当窗体大小变化的时候重新获取window.physicalSize,还有一种最简单的方法是继续使用window.physicalSize,但是把它放到全局变量里面去

1
2
3
4
5
ValueNotifier<double> windowsWidth =
ValueNotifier<double>(window.physicalSize.width / window.devicePixelRatio);

ValueNotifier<double> windowsHeight =
ValueNotifier<double>(window.physicalSize.height / window.devicePixelRatio);

然后在最外层build的时候,就是Saffold外面,刚刚好可以获取到MediaQuery.of(context).size的地方做一次值的变化,然后其他地方是不需要做Value监听的,因为窗体变化的时候flutter会自动rebuild saffold,这个时候优先触发这两个数值的改变,然后才去渲染后面的,渲染的时候自然取的就是新值了,不需要再做监听了,能偷点懒就偷点懒吧,那个值监听又要套一层,各种套娃看着太难受了

1
2
3
4
5
6
//当不是移动端的时候使用这个可以动态监听窗体变化
//如果是移动端的话,窗体不会变化
if (!isMobile.value) {
windowsWidth.value = MediaQuery.of(context).size.width;
windowsHeight.value = MediaQuery.of(context).size.height;
}

这样一来,就可以用最少的代码来完成移动端和桌面端的适配了

正在播放的磨砂效果

原本我是想“规矩”一点,把顶部和底部都拆到AppBarbottomSheet里面去,但是在写正在播放页面的时候改变了想法,因为做底部弹窗的时候,我希望直接弹出一个整屏的页面,而不是卡在AppBarbottomSheet中间的,这样即便做了磨砂玻璃的效果,看上去也…其实还挺好看,不过嗯,我有强迫症…

WechatIMG21777

这种正在播放的弹窗感觉就…不好看,所以还是延续我的暴力思路,直接把顶部和底部都写到自己的控件里面实现全屏的效果,同时在弹出的页面可以重新布局播放控制的按钮,这样给移动多放点按钮上去。

这里设置的是**BoxDecoration(color: Colors.black.withOpacity(0.7))**,我觉得其实可以设置到0.8左右

实现方法就是用Stack套一层,看代码

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
Stack(
children: <Widget>[
ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: ValueListenableBuilder<Map>(
valueListenable: activeSong,
builder: ((context, value, child) {
return ClipRRect(
//这里设置背景图片,其实可以直接Image的,不需要做个圆角矩形,我是复制过来的,无所谓啦
borderRadius: BorderRadius.circular(15),
child: (value.isEmpty)
? Image.asset("assets/images/logo.jpg")
: CachedNetworkImage(
imageUrl: value["url"],
fit: BoxFit.cover,
placeholder: (context, url) {
return AnimatedSwitcher(
child: Image.asset("assets/images/logo.jpg"),
duration:
const Duration(milliseconds: imageMilli),
);
},
),
);
}))),
Center(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: Container(
width: windowsWidth.value,
height: windowsHeight.value,
decoration:
BoxDecoration(color: Colors.black.withOpacity(0.8)), //这里是控制透明度的,然后前面的颜色设置成白色和黑色有不同的效果,都可以调整
child:... //放上层控件

1673685301772

歌词

歌词的实现使用的是pub上唯一的也是很不错的第三方组件flutter_lyric: ^2.0.4+6,实现起来并不难作者写了个demo而且把它放到网上去了。可以把源码里面的example下载下来对应着看就好了,歌词本身是“画出来的”,所以相当稳定,由于我的播放控制和进度条都是用StreamBuilder获取的,所以需要定义的东西比作者的例子里面少了不少

1
2
3
4
5
var lyricModel =
LyricsModelBuilder.create().bindLyricToMain(normalLyric).getModel(); //歌词文件normalLyric就是个字符串,照着抄就行
var lyricUI = UINetease(); //拿到UI文件,这个后面也可以自己改,字体颜色等等
var lyricPadding = 40.0; //直接复制了,可以改
var playing = true; //这个是控制歌词播放的,作者用来联动按钮控制歌词高亮,我不需要联动直接传了true,后面需要加到自己的暂停按钮里面去(其实不加好像也行...就一直true呗)

然后在initState里面加两条

1
lyricUI.highlight = true;  //设置高亮

然后构建歌词读取UI的Widget,并把它放到想要的位置

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
  Widget _buildReaderWidget() {
return StreamBuilder<PositionData>(
stream: _positionDataStream,
builder: (context, snapshot) {
final positionData = snapshot.data;
final position = positionData?.position.inMilliseconds ?? 0;
//我的定位信息是用StreamBuilder控制的,所以要套个头
return LyricsReader(
padding: EdgeInsets.symmetric(horizontal: lyricPadding),
model: lyricModel, //这里需要注意的是,因为这个StreamBuilder是一直在跑的,这里面可不敢放变量赋值,不然会一直run,如果跟我一样做了歌词的全局变量来读的话,检测值一定要放到StreamBuilder的外层!!!否则字幕会一直闪烁
position: position,
lyricUi: lyricUI,
playing: playing,
//设置大小
size: Size(windowsWidth.value / 2, windowsHeight.value / 2),
emptyBuilder: () => Center(
child: Text(
"No lyrics",
style: lyricUI.getOtherMainTextStyle(),
),
),
selectLineBuilder: (progress, confirm) {
return Row(
children: [
IconButton(
onPressed: () {
LyricsLog.logD("点击事件");
confirm.call();
//这里是更新seek的位置,不过我那个进度条做好了监听,所以不需要setState,直接改就可以了
//setState(() {
widget.player.seek(Duration(milliseconds: progress));
// });
},
icon: Icon(Icons.play_arrow, color: kGrayColor)),
Expanded(
child: Container(
decoration: BoxDecoration(color: kGrayColor),
height: 1,
width: double.infinity,
),
),
Text(
//这是自己写的方法,用于把Duration改成小时、分钟、秒这样的字符串
formatDurationMilliseconds(progress),,
style: TextStyle(color: kGrayColor),
)
],
);
},
);
});
}

String formatDurationMilliseconds(int _duration) {
Duration _dura = Duration(milliseconds: _duration);
if (_dura.inHours != 0) {
String hours = _dura.inHours.toString().padLeft(0, '2');
String minutes = _dura.inMinutes.remainder(60).toString().padLeft(2, '0');
String seconds = _dura.inSeconds.remainder(60).toString().padLeft(2, '0');
return "$hours:$minutes:$seconds";
} else {
String minutes = _dura.inMinutes.remainder(60).toString().padLeft(2, '0');
String seconds = _dura.inSeconds.remainder(60).toString().padLeft(2, '0');
return "$minutes:$seconds";
}
}

至此歌词问题就解决了,后面只用传入不同的normalLyric就可以了,需要注意不要跑到进度条的值监控里面了,不然一直RUN的话歌词会不停的闪烁

只是目前控件有一个问题,当你拖动歌词定位到某一句的时候,点击确定确实可以定位到想要的位置,但是歌词的滚动会被卡住。我看作者的demo里面也是这个样子,所以给作者提了个issue,看看后面怎么解决吧

1673766650968

然后关键的就是…怎么批量去找歌词…说不得可能还是要去用网易云的api了…