Flutter写个Nas音乐播放器(二)

2023-01-11 02:36≈ 684字≈ 4分钟

呐喊!想要通过路由做局部刷新的,千万要自己写路由,不要用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里面找了一个小的等宽字体来用,专门用于进度条前面的数字,避免位移

  1. 声明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. 局部使用
1   Text(
2           formatSongDuration(widget.position),
3           style: TextStyle(
4               color: borderColor, fontFamily: 'ChivoMono', fontSize: 12),
5         )
  1. 全局使用
1return MaterialApp(
2      debugShowCheckedModeBanner: false,
3      title: 'XiuMusic',
4      theme: ThemeData(fontFamily: 'NotoSansSC'),
5      home: MainScreen(),
6    );

4. Todo List

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

Todo List直接在Github上更新了

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