Flutter开发一个云音乐APP(包含接口地址,亲测可用)

最近加了个移动互联实验室,学了flutter后打算自己做一个app练练手,了解到了网易云音乐接口便决定做一个简单的云音乐APP(仅供学习)

一、项目框架搭建

1、搭建基础的项目框架

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第1张图片
介绍一个每个文件夹的用途
animation : 存放自定义动画
config : 存放配置文件(如电影、音乐接口地址等静态资源)
data : 存放本地数据文件
model : 网络元素model
pages : app页面
utils : 工具类
widget : 自定义组件
main.dart : app入口文件

配置本地文件夹assets,之后在登录页会用到文件夹中的两张图片
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第2张图片
在这里插入图片描述
需要的接口地址
constant.dart

  /// 接口
  // 音乐热歌榜接口地址  :  https://music.163.com/api/playlist/detail?id=3778678
  static const String musicApiUrl_host = "music.163.com";
  static const String musicApiUrl_path = "/api/playlist/detail";

  // 音乐搜索  http://musicapi.leanapp.cn/search?keywords=
  static const String musicSearchUrl_host = "musicapi.leanapp.cn";
  static const String musicSearchUrl_path = "/search";

  // 音乐评论  http://musicapi.leanapp.cn/comment/music?id=27588968&limit=1
  static const String musicCommentUrl_host = "musicapi.leanapp.cn";
  static const String musicCommentUrl_path = "/comment/music";

  // 音乐播放地址 https://api.imjad.cn/cloudmusic/?type=song&id=112878&br=128000
  // 音乐歌词    https://api.imjad.cn/cloudmusic/?type=lyric&id=112878&br=128000
  static const String musicPlayLyricUrl_host = "api.imjad.cn";
  static const String musicPlayLyricUrl_path = "/cloudmusic";

  // 个人歌单
  // http://music.163.com/api/user/playlist/?offset=0&limit=100&uid=1927677638
  static const String personalPlayListApiUrl_host = "music.163.com";
  static const String personalPlayListApiUrl_path = "/api/user/playlist";

  // 个人信息
  // https://music.163.com/api/v1/user/detail/1927677638
  static const String personalInfoUrl_host = "music.163.com";
  static const String personalInfoUrl_path = "/api/v1/user/detail/";

  // 歌单详情 https://music.163.com/api/playlist/detail?id=24381616
  static const String playlistDetailUrl_host = "music.163.com";
  static const String playlistDetailUrl_path = "/api/playlist/detail";

  // 歌单评论 http://musicapi.leanapp.cn/comment/playlist?id=1
  static const String playlistCommentUrl_host = "musicapi.leanapp.cn";
  static const String playlistCommentUrl_path = "/comment/playlist";

  // 精品歌单 http://musicapi.leanapp.cn/top/playlist/highquality/华语
  static const String playlistHighQualityUrl_host = "musicapi.leanapp.cn";
  static const String playlistHighQualityUrl_path = "/top/playlist/highquality";

  // 相似歌单  http://musicapi.leanapp.cn/simi/playlist?id=347230
  static const String playlistSimiUrl_host = "musicapi.leanapp.cn";
  static const String playlistSimiUrl_path = "/simi/playlist";

  // 歌手榜单  http://music.163.com/api/artist/list   http://musicapi.leanapp.cn/artist/list
  static const String singerRankUrl_host = "musicapi.leanapp.cn";
  static const String singerRankUrl_path = "/artist/list";

  // 歌手热门歌曲 http://music.163.com/api/artist/5781  歌手信息和热门歌曲
  static const String singerTopMusicUrl_host = "music.163.com";
  static const String singerTopMusicUrl_path = "/api/artist/";

  // 歌手专辑列表 http://music.163.com/api/artist/albums/3684  歌手id  http://musicapi.leanapp.cn/artist/album?id=6452&limit=30
  static const String singerAlbumUrl_host = "music.163.com";
  static const String singerAlbumUrl_path = "/api/artist/albums/";

  // 专辑详情  https://music.163.com/api/album/90743831   专辑id
  static const String albumDetailUrl_host = "music.163.com";
  static const String albumDetailUrl_path = "/api/album/";

  // 歌手描述 http://musicapi.leanapp.cn/artist/desc?id=6452
  static const String singerDescUrl_host = "musicapi.leanapp.cn";
  static const String singerDescUrl_path = "/artist/desc";

  // 歌曲MV  http://music.163.com/api/mv/detail?id=319104&type=mp4

2、构建登录页

登录页基本实现:
a)、云音乐logo
b)、"云音乐"文本
c)、一个用户名输入框和一个密码输入框,账号密码未输入情况下,“登录"按钮不可点击。当输入账号密码时,登录按钮变为白色且可点击
d)、密码输入框加入"密码是否可见按钮”(小眼睛按钮)支持点击切换密码是否可见(图片资源在第一步已经导入)
e)、点击登录按钮,校验与已经设定的密码是否相符,并弹出密码正确与否提示
f)、登录页面使用“轻量级数据存储”形式(SharedPreferences)存储输入的账户和密码,下次打开时自动载入账户和密码且允许点击登录。

由于登录页实现比较简单,这里就不过多解释了,直接放上完成图

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第3张图片
登录后禁止返回

// Navigator.push(context, MaterialPageRoute(
      //   builder: (context) => Tabs())
      // );
      // 禁止返回上个页面
      Navigator.of(context).pushAndRemoveUntil(
          new MaterialPageRoute(builder: (context) => Tabs()
          ), (route) => route == null);

3、Tab页

登录成功后跳转到tab页
该tab页包括4个页面:歌单广场、歌手排行榜、音乐热歌榜,个人中心页。在flutter中通过BottomNavigationBar创建底部tab选项

在这里插入图片描述

Tabs代码

import 'package:flutter/material.dart';
import 'package:flutter_app_realtimeinfo/pages/music_page.dart';
import 'package:flutter_app_realtimeinfo/pages/personal_page.dart';
import 'package:flutter_app_realtimeinfo/pages/playlist_page.dart';
import 'package:flutter_app_realtimeinfo/pages/singer_page.dart';

class Tabs extends StatefulWidget {
     
  @override
  _TabsState createState() => _TabsState();
}

class _TabsState extends State<Tabs> {
     

  int _currentIndex = 0;

  final List<Widget> _pageList = [
    PlaylistPage(),// 歌单广场页
    SingerPage(),// 歌手榜单页
    MusicPage(),// 热歌榜
    PersonalPage()// 个人中心
  ];

  final List<BottomNavigationBarItem> bottomNavigationBarItems = [
    BottomNavigationBarItem(icon: Icon(Icons.featured_play_list),title: Text('歌单')),
    BottomNavigationBarItem(icon: Icon(Icons.mic_none_outlined),title: Text('歌手')),
    BottomNavigationBarItem(icon: Icon(Icons.music_note_outlined),title: Text('热歌榜')),
    BottomNavigationBarItem(icon: Icon(Icons.person),title: Text('我的')),
  ];

  @override
  void initState(){
     
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
     
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        type: BottomNavigationBarType.fixed,// BottomNavigationBar 超过3个之后 添加
        currentIndex: this._currentIndex,
        onTap: (int index){
     
          setState((){
     
            _currentIndex = index;
          });
        },
        items: this.bottomNavigationBarItems,
      ),
      body: IndexedStack(
        index: _currentIndex,
        children: _pageList,
      ),
    );
  }
}

4、歌单广场页

歌单广场页面的构造十分简单,分成两个部分:展示6条精品歌单的轮播图、精品歌单的网格布局。点击歌单后通过歌单id获取歌单数据并跳转到歌单详情页。

这个页面需要注意的是页面保持的方法。

先介绍一下页面保持是什么。一般情况下,我们使用tab切换的时候希望操作完毕之后,能够记住上个页面的状态,但是使用Flutter的BottomNavigationBar的时候默认是不记录页面状态的,即切换页面会导致重新加载。这对我们来说很痛苦,而且非常的浪费资源。

如果要想我们的页面在切换完毕之后记录之前的状态。需要一下几个步骤:

1、在包含BottomNavigationBar的页面中,body应该返回IndexedStack或者Pageview
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第4张图片
2、想要保持状态的页面必须是StatefullWidget,并且在相应的页面的state中混入AutomaticKeepAliveClientMixin类重写wantKeepAlive方法并返回true

在这里插入图片描述
完成图:

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第5张图片

5、歌单详情页

电影详情页基本实现:

a)、歌单封面、歌单名称、歌单描述和歌单创建者

b)、歌单歌曲列表

直接上图
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第6张图片
这个页面也不是很难,大致分为两个部分:头部和歌单歌曲列表,头部包括歌单封面、歌单名称、创建者、描述、收藏量和评论数(点击创建者后进入用户详情页,点击评论进去歌单评论区)

6、歌单评论区

歌单评论区实现:

a)、显示歌单评论

完成图:
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第7张图片
歌单评论区整体为一个评论列表组件,点击回复可以查看回复的评论

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第8张图片

7、用户中心页

用户中心页实现:

a)、用户头像、昵称、关注数量、粉丝数和等级

b)、用户基本信息

c)、音乐品味

c)、创建的歌单和收藏的歌单

d)、点击歌单后根据id跳转到歌单详情页

完成图:
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第9张图片
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第10张图片

8、歌手榜单页

歌手榜单页实现:

a)、华语男歌手榜单(后期会进行优化,实现多种歌手榜单的切换)

完成图:

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第11张图片
该页面比较简单,就是一个歌手列表,通过点击歌手列表项进入歌手详情页

9、歌手详情页

歌手详情页实现:

a)、歌手名称、歌手照片

b)、艺人百科

c)、歌手热门歌曲

b)、歌手专辑

完成图:
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第12张图片
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第13张图片
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第14张图片

10、个人中心页

个人中心页实现:

a)、个人网易云头像和昵称

b)、创建的歌单和收藏的歌单

c)、点击歌单后根据id获取歌单详情

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第15张图片

11、云音乐热歌榜

该页面主要包括两大部分,上方的搜索框和下方的音乐列表

音乐热歌榜实现

a)、点击搜索框进入音乐搜索页面

b)、音乐列表展示今日热歌,点击音乐列表项进入音乐详情页

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第16张图片

12、搜索音乐

搜索音乐页面基本实现:

a)、搜索页面使用数据库存储历史记录,支持单条记录的删除以及清空所有历史记录,点击搜索历史再次根据关键词搜索文件。

b)、根据关键词搜索音乐并展示搜索结果条目数以及搜索结果。

c)、访问接口搜索耗时较长,设计“正在搜索”页面。

d)、搜索结果为零时,设计提醒搜索结果为零页面。

进入搜索音乐页
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第17张图片
获取搜索框焦点后

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第18张图片
当搜索后将搜索记录保存到历史记录列表,并搜索音乐

例:当搜索"IU"时

搜索结果:

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第19张图片

13、音乐详情页

音乐详情页基本实现
a)、音乐播放
b)、歌词滚动显示
c)、用户评论

完成图:

Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第20张图片
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第21张图片
整个页面分为两个部分:音乐播放器和用户评论

用户评论实现起来比较简单,在这就不说了

音乐播放器应该算整个app里面比较难的,所以我在这里一步一步分析

我在这将音乐播放器分为三个部分:上方的黑胶唱片旋转动画、中间的进度条和播放按钮、最下方的歌词字幕

1、黑胶唱片旋转动画

//旋转图片组件
  Widget _buildRotationTransition() {
     
    return Container(
      child: RotationTransition(
        //设置动画的旋转中心
        alignment: Alignment.center,
        //动画控制器
        turns: _animController,
        //将要执行动画的子view
        child: Container(
          width: 130,
          height: 130,
          child: ClipOval(
            child: Image.network(widget.music.picSmall),
          ),
        ),
      ),
    );
  }

2、音乐进度条控制音乐播放

/// 播放进度条组件
typedef PlayOnTapCallback = void Function(AudioPlayerState _playerState);
typedef DurationOnTapCallback = void Function(Duration p);

class MusicPlayerSlider extends StatefulWidget {
     

  String audioUrl;
  double volume;
  String onPlaying;
  Color color;
  bool isLocal;
  PlayOnTapCallback playOnTapCallback;// 点击播放暂停回调
  DurationOnTapCallback durationOnTapCallback;// 播放时间

  MusicPlayerSlider(
      {
     
        @required this.audioUrl,
        this.volume : 1.0,
        this.onPlaying,
        this.color : Colors.white,
        this.isLocal : false,
        this.playOnTapCallback,
        this.durationOnTapCallback
      });

  @override
  _MusicPlayerSliderState createState() => _MusicPlayerSliderState();
}

class _MusicPlayerSliderState extends State<MusicPlayerSlider> {
     

  AudioPlayer audioPlayer;

  Duration duration;
  Duration position;
  double sliderValue;

  // 音乐状态
  AudioPlayerState _playerState;

  @override
  void initState(){
     
    super.initState();
    audioPlayer = new AudioPlayer();
    //durationHandler会回调音频总时长,positionHandler会回调播放进度
    audioPlayer.onPlayerCompletion.listen((event) {
     
      print("播放完成");
    });// 播放完成
    audioPlayer.onPlayerError.listen((e) {
     
      print(e);
    });// 播放错误
    audioPlayer.onAudioPositionChanged.listen((Duration  p) {
     // 播放时长改变
      setState(() {
     
        sliderValue = p.inSeconds/duration.inSeconds;
      });
      widget.durationOnTapCallback(p);
    });
    audioPlayer.onPlayerStateChanged.listen((AudioPlayerState s) {
     // 播放器状态改变
        setState(() {
     
          _playerState = s;
        });
    });
    audioPlayer.onDurationChanged.listen((Duration d) {
     // 设置歌曲时长
      setState(() => duration = d);
    });
  }
  //组件完全销毁时回调,主要在里面做一些移除监听的操作
  @override
  void dispose(){
     
    audioPlayer.release();
    super.dispose();
  }


  // 播放音乐
  _play() {
     
    audioPlayer.play(
        widget.audioUrl,
        isLocal: widget.isLocal,
        volume: widget.volume
    );
  }

  // 暂停音乐
  _pause(){
     
    audioPlayer.pause();
  }

  // 停止音乐
  _stop(){
     
    audioPlayer.stop();
  }

  @override
  Widget build(BuildContext context) {
     
    return Container(
      child: Column(
        children: [
          Slider(
            onChanged: (newValue) {
     
              if (duration != null) {
     
                int seconds = (duration.inSeconds * newValue).round();
                audioPlayer.seek(Duration(seconds: seconds));
              }
            },
            value: sliderValue ?? 0.0,
            activeColor: widget.color,
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              IconButton(
                onPressed: (){
     
                  if(_playerState == AudioPlayerState.PLAYING)
                    _pause();
                  else
                    _play();
                  print(_playerState);
                  widget.playOnTapCallback(_playerState);
                },
                icon: _playerState == AudioPlayerState.PLAYING ? Icon(Icons.pause_circle_filled_outlined) : Icon(Icons.play_circle_fill),
              ),
              IconButton(
                onPressed: (){
     
                  _stop();
                  setState((){
     
                    sliderValue = 0.0;
                  });
                  print(_playerState);
                  widget.playOnTapCallback(_playerState);
                },
                icon: Icon(Icons.stop),
              )
            ],
          )
        ],
      ),
    );
  }

}

// 播放器组件
  Widget _playWidget(){
     
    return Container(
      padding: EdgeInsets.only(bottom: 10.0),
      child: Stack(
        children: [
          Container(
            height: 200.0,
            decoration: BoxDecoration(
              image: DecorationImage(
                image: NetworkImage("https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=4225846040,3889426807&fm=26&gp=0.jpg"),
                fit: BoxFit.cover,
                colorFilter: new ColorFilter.mode(
                  Colors.black54,
                  BlendMode.overlay,
                ),
              ),
            ),
          ), //黑胶唱片图片
          Container(
            height: 200.0,
            child: BackdropFilter(
              filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
              child: Opacity(
                opacity: 0.2,
                child: Container(
                  decoration: BoxDecoration(
                    color: Colors.grey.shade900,
                  ),
                ),
              ),
            )
          ), //模糊图层
          Column(
            children: [
              Container(
                padding: EdgeInsets.only(top: 30.0),
                child: _buildRotationTransition(),
              ),
              Container(
                padding: EdgeInsets.only(top: 60.0),
                child: MusicPlayerSlider(audioUrl: widget.music.playUrl != null ? widget.music.playUrl : "",
                          playOnTapCallback: (state){
     
                            if(state == AudioPlayerState.PLAYING){
     
                              _animController.stop();// 停止
                            }else if(state == AudioPlayerState.PAUSED){
     
                              _animController.forward();//正向开始
                            }else if(state == AudioPlayerState.STOPPED){
     
                              _animController.reset();// 重置
                              _animController.forward();
                            }else{
     // null
                              _animController.forward();//正向开始
                            }
                          },durationOnTapCallback: (p){
     
                            setState(() {
     
                              _inSeconds = p.inSeconds;
                            });
                          },),
              )
            ],
          ),//播放进度条 和 旋转图片
        ],
      ),
    );
  }

3、歌词字幕滚动显示

///字幕控件
class Subtitle extends StatefulWidget {
     

  List<LyricEntry> data;
  TextStyle selectedTextStyle;
  TextStyle unSelectedTextStyle;
  double diameterRatio;
  double itemExtent;
  int inSeconds;

  Subtitle(this.data,this.inSeconds,{
     this.selectedTextStyle,this.unSelectedTextStyle,this.diameterRatio,this.itemExtent});

  @override
  _SubtitleState createState() => _SubtitleState();
}

class _SubtitleState extends State<Subtitle> {
     

  int _currentIndex;
  ScrollController _controller;

  // 控制滑动的计时器。
  Timer _timer;

  @override
  void initState(){
     
    super.initState();
    _initController();// 初始化滚动监听
    _startTimer();
  }
  // 开启计算器
  void _startTimer() {
     
    // 计时器(`Timer`)组件的定期(`periodic`)构造函数,创建一个新的重复计时器。
    _timer = Timer.periodic(Duration(microseconds: 300), (timer) {
     
      if(widget.inSeconds != null){
     
        for(int i = 0 ;i < widget.data.length;i++){
     
          if(widget.inSeconds == widget.data[i].time){
     
            _controller.animateTo((45 * i).toDouble(), duration: Duration(milliseconds: 300), curve: Curves.linear);
          }
        }
      }
    });
  }

  void _initController(){
     
    _controller = new ScrollController();

    _controller.addListener(() {
     // 监听滚动位置 来将歌词高亮
      setState(() {
     
        _currentIndex = _controller.offset ~/ 45;
      });
    });
  }

  @override
  void dispose() {
     
    //为了避免内存泄露,需要调用_controller.dispose
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
     
    if (widget.data == null || widget.data.length == 0) {
     
      return Container(
        padding:EdgeInsets.only(top:50.0),
        alignment: Alignment.center,
        child: Text("加载歌词失败"),);
    }
    return ListWheelScrollView.useDelegate(
      controller: _controller,
      diameterRatio: widget.diameterRatio,
      itemExtent: widget.itemExtent,
      childDelegate: ListWheelChildBuilderDelegate(
          builder: (context, index) {
     
            return Container(
              alignment: Alignment.center,
              child: Text(
                '${widget.data[index].word}',
                style: _currentIndex == index
                    ? widget.selectedTextStyle
                    : widget.unSelectedTextStyle,
              ),
            );
          },
          childCount: widget.data.length
      ),
    );
  }

}

歌词根据音乐时间滚动效果

歌词格式:时间戳+歌词
00:00 歌词:
00:25 我要穿越这片沙漠
00:28 找寻真的自我
00:30 身边只有一匹骆驼陪我
00:34 这片风儿吹过
00:36 那片云儿飘过

首先将歌词转换成lyricEntry类 类包含秒数和歌词,即将时间戳换成秒数
例:
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第22张图片
我是通过进度条的回调函数将音乐播放的时间赋值给父组件的inSeconds,然后将这个参数传给字幕组件。

然后设置定时器来判断歌词应该滚动到哪个地方

在滚动监听里判断满足条件时歌词高亮

14、App图标和名称

android :
Flutter开发一个云音乐APP(包含接口地址,亲测可用)_第23张图片

你可能感兴趣的:(移动开发,android,ios,flutter,android,studio,web,app)