最近加了个移动互联实验室,学了flutter后打算自己做一个app练练手,了解到了网易云音乐接口便决定做一个简单的云音乐APP(仅供学习)
介绍一个每个文件夹的用途
animation : 存放自定义动画
config : 存放配置文件(如电影、音乐接口地址等静态资源)
data : 存放本地数据文件
model : 网络元素model
pages : app页面
utils : 工具类
widget : 自定义组件
main.dart : app入口文件
配置本地文件夹assets,之后在登录页会用到文件夹中的两张图片
需要的接口地址
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
登录页基本实现:
a)、云音乐logo
b)、"云音乐"文本
c)、一个用户名输入框和一个密码输入框,账号密码未输入情况下,“登录"按钮不可点击。当输入账号密码时,登录按钮变为白色且可点击
d)、密码输入框加入"密码是否可见按钮”(小眼睛按钮)支持点击切换密码是否可见(图片资源在第一步已经导入)
e)、点击登录按钮,校验与已经设定的密码是否相符,并弹出密码正确与否提示
f)、登录页面使用“轻量级数据存储”形式(SharedPreferences)存储输入的账户和密码,下次打开时自动载入账户和密码且允许点击登录。
由于登录页实现比较简单,这里就不过多解释了,直接放上完成图
// Navigator.push(context, MaterialPageRoute(
// builder: (context) => Tabs())
// );
// 禁止返回上个页面
Navigator.of(context).pushAndRemoveUntil(
new MaterialPageRoute(builder: (context) => Tabs()
), (route) => route == null);
登录成功后跳转到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,
),
);
}
}
歌单广场页面的构造十分简单,分成两个部分:展示6条精品歌单的轮播图、精品歌单的网格布局。点击歌单后通过歌单id获取歌单数据并跳转到歌单详情页。
这个页面需要注意的是页面保持的方法。
先介绍一下页面保持是什么。一般情况下,我们使用tab切换的时候希望操作完毕之后,能够记住上个页面的状态,但是使用Flutter的BottomNavigationBar的时候默认是不记录页面状态的,即切换页面会导致重新加载。这对我们来说很痛苦,而且非常的浪费资源。
如果要想我们的页面在切换完毕之后记录之前的状态。需要一下几个步骤:
1、在包含BottomNavigationBar的页面中,body应该返回IndexedStack或者Pageview
2、想要保持状态的页面必须是StatefullWidget,并且在相应的页面的state中混入AutomaticKeepAliveClientMixin类重写wantKeepAlive方法并返回true
电影详情页基本实现:
a)、歌单封面、歌单名称、歌单描述和歌单创建者
b)、歌单歌曲列表
直接上图
这个页面也不是很难,大致分为两个部分:头部和歌单歌曲列表,头部包括歌单封面、歌单名称、创建者、描述、收藏量和评论数(点击创建者后进入用户详情页,点击评论进去歌单评论区)
歌单评论区实现:
a)、显示歌单评论
完成图:
歌单评论区整体为一个评论列表组件,点击回复可以查看回复的评论
用户中心页实现:
a)、用户头像、昵称、关注数量、粉丝数和等级
b)、用户基本信息
c)、音乐品味
c)、创建的歌单和收藏的歌单
d)、点击歌单后根据id跳转到歌单详情页
歌手榜单页实现:
a)、华语男歌手榜单(后期会进行优化,实现多种歌手榜单的切换)
完成图:
该页面比较简单,就是一个歌手列表,通过点击歌手列表项进入歌手详情页
歌手详情页实现:
a)、歌手名称、歌手照片
b)、艺人百科
c)、歌手热门歌曲
b)、歌手专辑
个人中心页实现:
a)、个人网易云头像和昵称
b)、创建的歌单和收藏的歌单
c)、点击歌单后根据id获取歌单详情
该页面主要包括两大部分,上方的搜索框和下方的音乐列表
音乐热歌榜实现
a)、点击搜索框进入音乐搜索页面
b)、音乐列表展示今日热歌,点击音乐列表项进入音乐详情页
搜索音乐页面基本实现:
a)、搜索页面使用数据库存储历史记录,支持单条记录的删除以及清空所有历史记录,点击搜索历史再次根据关键词搜索文件。
b)、根据关键词搜索音乐并展示搜索结果条目数以及搜索结果。
c)、访问接口搜索耗时较长,设计“正在搜索”页面。
d)、搜索结果为零时,设计提醒搜索结果为零页面。
例:当搜索"IU"时
搜索结果:
音乐详情页基本实现
a)、音乐播放
b)、歌词滚动显示
c)、用户评论
完成图:
用户评论实现起来比较简单,在这就不说了
音乐播放器应该算整个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类 类包含秒数和歌词,即将时间戳换成秒数
例:
我是通过进度条的回调函数将音乐播放的时间赋值给父组件的inSeconds,然后将这个参数传给字幕组件。
然后设置定时器来判断歌词应该滚动到哪个地方
在滚动监听里判断满足条件时歌词高亮