CSDN我打算过段时间再更新了,现在都在b站不断更新,包括进度,更新内容的演示视频
点击进入b站更新
bilibili_getx gitee地址
项目仓库转到gitee上去了,不用github了
0.1 速览界面
视频弹幕(计时器+动画+position.fill定位)
我的界面&扫描
视频界面
Jverify一键登录界面
统计图表fl_chart
0.2 更新日志
更新日期 | 更新时间 | 版本 |
---|---|---|
5月18日 | 1、规划项目框架;2、内容写至2.5 | 0.0.1 |
5月19日 | 1、上传代码至github;2、配置路由;3、main主页增加底部导航栏;4、home主页增加顶部TabBar及滑动效果 | 0.0.2 |
5月20日 | 完成的任务:1、更新代码至github;2、构建home主页视频推荐界面的基本布局;3、home主页推荐界面上拉刷新&下拉刷新;4、实现home主页推荐界面的请求网络数据功能;5、app主题部分字体大小修改调整;6、创建共享数据provider及使用provider;明日任务:1、home主页头像点击跳转界面;2、视频界面; | 0.0.3 |
5月21日 | 完成的任务:1、视频播放功能;2、路由跳转视频界面;3、自定义进度条及视频播放按钮样式 | 0.0.4 |
5月22日 | 1、视频简介;视频标签;目前把界面先搞出来了,有些交互,点击的效果,后面再搞;2、代码更新; | 0.0.5 |
5月23日 | 学习bloc管理模式以及Steam订阅模式 | |
5月24日 | 学习bloc,准备把登录功能用Bloc做一下 | |
5月25日 | 学习stream流, | |
5月25-5月29日 | 一些话:好几天没更新了,主要是找工作去了,找工作也个把月了,光面试,没结果…我就是希望了能找个7000+的工作,flutter框架和Android原生的,咋就这么难呢,我到底问题出在哪里????去年毕业的,七月份开始去考研了,二战,失败了,是二战失败的人不配工作吗,我接下来只想好好的工作,我到底是哪一步做错了!?为啥啊,有大佬能指点迷津吗,我到底问题出在哪里?更新内容:1、登录界面和直播推荐见面;2、更新了代码,代码仓库换成gitee,github上传太慢了 | 0.0.6 |
5月30-5月31日 | 唠嗑:凌晨一点我竟然还在写blog,我可能是爱上Flutter吧,做一个单推的臭DD。更新内容:视频弹幕功能完成、代码更新至gitee上啦 | 0.0.7 |
6月1日 | 之后准备做一些没用过的插件,一些有趣的功能,做做二维码扫描吧,之前Android原生有做过,flutter不知道咋样,看看扫描登录怎么做吧,哈哈 | |
6月2-6月4日 | 完成了我的界面的初步布局、有二维码扫描的功能,但是登陆还未实现,并且之后想用一下极光验证,看看能不能本机号码一键登录,如果是普通的登陆,需要原始密码加盐,再RSA加密。 | 0.0.8 |
6月5日 | 手机号码一键登录界面初步布局及点击事件 | 0.0.9 |
6月6日-6月9日 | 对接b站视频数据接口可以播放视频,进度条带小电视图标,更换视频插件为chewie,对整体代码做了优化,对关键处添加了注释 | 0.1.0 |
6月10日 | 采用极光认证,实现home页面的一键登录功能,对接极光认证(Jverify) | 0.1.1 |
6月11日-6月13日 | 1、图表;2、代码更新 | |
6月14日-6月17日 | 1、发布界面简要;2、地图 | 0.1.3 |
0.3 项目地址
点击进入项目gitee地址
代码目前更新至6月17日
1、前言
之前是用vue写了个仿bilibili的应用,这次用新学的flutter来做,要求仿的更像些,细节处更加完善,来练一练flutter
2、前期准备
2.1 静态数据
先准备bilibili里边用到的一些图标,比如底部的icon、顶部的icon、侧边栏的icon、视频json数据等等。后面开发过程中,若有新增的数据,再继续添加。
资料来源
Icon图标
json数据(上b站,F12,看网络,找点数据很快的)
2.2 创建项目
2.3 appId、APP名称、APP图标、启动图、配置静态数据目录
2.4 APP主题
先建立一些主要的文件夹,之后新增的就再建一些
create_material_color.dart
import 'package:flutter/material.dart';
MaterialColor createMaterialColor(Color color) {
List strengths = <double>[.05];
Map<int, Color> swatch = {};
final int r = color.red, g = color.green, b = color.blue;
for (int i = 1; i < 10; i++) {
strengths.add(0.1 * i);
}
strengths.forEach((strength) {
final double ds = 0.5 - strength;
swatch[(strength * 1000).round()] = Color.fromRGBO(
r + ((ds < 0 ? r : (255 - r)) * ds).round(),
g + ((ds < 0 ? g : (255 - g)) * ds).round(),
b + ((ds < 0 ? b : (255 - b)) * ds).round(),
1,
);
});
return MaterialColor(color.value, swatch);
}
一个是暗黑主题,一个是普通模式的主题
app_theme.dart
import 'package:flutter/material.dart';
import 'create_material_color.dart';
class HYAppTheme {
//共有属性
static const double xSmallFontSize = 14;
static const double smallFontSize = 16;
static const double normalFontSize = 22;
static const double largeFontSize = 24;
static const double xLargeFontSize = 26;
//普通模式
static const Color norTextColors = Colors.red;
static final ThemeData norTheme = ThemeData(
primarySwatch: createMaterialColor(Colors.white), //包含大部分颜色设置
canvasColor: Color.fromRGBO(241, 242, 244, 1), //APP背景颜色
textTheme: const TextTheme(
bodySmall: TextStyle(fontSize: xSmallFontSize),
displaySmall: TextStyle(fontSize: smallFontSize),
displayMedium: TextStyle(fontSize: normalFontSize),
displayLarge: TextStyle(fontSize: largeFontSize),
),
);
//暗黑模式
static const Color darkTextColors = Colors.green;
static final ThemeData darkTheme = ThemeData(
primarySwatch: createMaterialColor(Color.fromRGBO(24, 25, 27, 1)),
canvasColor: Color.fromRGBO(0, 0, 0, 1),
textTheme: const TextTheme(
bodySmall: TextStyle(fontSize: xSmallFontSize),
displaySmall: TextStyle(fontSize: smallFontSize),
displayMedium: TextStyle(fontSize: normalFontSize),
displayLarge: TextStyle(fontSize: largeFontSize),
),
);
}
2.5 屏幕适配
size_fit.dart
import 'dart:ui';
class HYSizeFit {
static double physicalWidth = 0.0;
static double physicalHeight = 0.0;
static double screenWidth = 0.0;
static double screenHeight = 0.0;
static double dpr = 0.0;
static double statueHeight = 0.0;
static double rpx = 0.0;
static double px = 0.0;
static void initialize() {
//物理分辨率
physicalWidth = window.physicalSize.width;
physicalHeight = window.physicalSize.height;
//获取dpr
dpr = window.devicePixelRatio;
screenWidth = physicalWidth / dpr;
screenHeight = physicalHeight / dpr;
//状态栏高度
statueHeight = window.padding.top / dpr;
//计算rpx的大小
rpx = screenWidth / 750;
px = screenWidth / 750 * 2;
}
//适配IOS
static double setRpx(double size) {
return rpx * size;
}
static double setPx(double size) {
return px * size;
}
}
int_extension.dart
import '../../ui/shared/size_fit.dart';
extension IntFit on int {
double get px {
return HYSizeFit.setPx(this.toDouble());
}
double get rpx {
return HYSizeFit.setRpx(this.toDouble());
}
}
2.6 配置路由
router.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/ui/pages/dynamic_circle/dynamic_circle.dart';
import 'package:flutter_bilibili/ui/pages/home/home.dart';
import 'package:flutter_bilibili/ui/pages/main/main.dart';
import 'package:flutter_bilibili/ui/pages/mine/mine.dart';
import 'package:flutter_bilibili/ui/pages/vip_shop/vip_shop.dart';
class HYRouter {
static const String initialRoute = HYMainScreen.routeName; //初始化路由
static Map<String, WidgetBuilder> routes = {
HYMainScreen.routeName: (ctx) =>HYMainScreen(),
HYHomeScreen.routeName: (ctx) => HYHomeScreen(),
HYDynamicCircleScreen.routeName: (ctx) => HYDynamicCircleScreen(),
HYMineScreen.routeName: (ctx) => HYMineScreen(),
HYVipShopScreen.routeName: (ctx) => HYVipShopScreen()
};
//后改
static final RouteFactory generateRoute = (setting) {
return null;
};
//找不到页面
static final RouteFactory unKnowRoute = (setting) {
return null;
};
}
目前思路为
A、lib下的main.dart为主入口,包括主题、路由等。
B、ui中pages是app暂定的页面,pages中的main.dart负责底部导航栏,页面切换的配置
C、页面包括home主页、mine我的、dynamic_circle动态、vip_shop会员购
2.7 底部导航栏
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/ui/pages/main/initialize_items.dart';
class HYMainScreen extends StatefulWidget {
static const String routeName = "/"; //起始路由
@override
State<HYMainScreen> createState() => _HYMainScreenState();
}
class _HYMainScreenState extends State<HYMainScreen> {
int _currentIndex = 0; //当前显示的page编号
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: pages,
),
bottomNavigationBar: BottomNavigationBar(
selectedFontSize: 10.px, //选中时字体大小
unselectedFontSize: 10.px, //未选中时字体大小
selectedItemColor: Color.fromRGBO(210, 83, 125, 1), //选中时字体颜色
type: BottomNavigationBarType.fixed, //显示label标签,而不是隐藏label
currentIndex: _currentIndex, //当前显示的页面
items: items,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
);
}
}
initialize_items.dart
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/ui/pages/home/home.dart';
import '../dynamic_circle/dynamic_circle.dart';
import '../mine/mine.dart';
import '../vip_shop/vip_shop.dart';
final _iconSize = 18.px;
final _activeIcon = 18.px;
final List<Widget> pages = [
HYHomeScreen(),
HYDynamicCircleScreen(),
HYVipShopScreen(),
HYMineScreen()
];
final List<BottomNavigationBarItem> items = [
buildBottomNavigationBarItem("首页", "home"),
buildBottomNavigationBarItem("动态", "dynamic"),
buildBottomNavigationBarItem("会员购", "vip"),
buildBottomNavigationBarItem("我的", "mine"),
];
BottomNavigationBarItem buildBottomNavigationBarItem(
String title, String iconName) {
return BottomNavigationBarItem(
label: title,
icon: Image.asset(
"assets/image/icon/${iconName}_custom.png",
width: _iconSize,
height: _iconSize,
gaplessPlayback: true, //gaplessPlayback: 原图片保持不变,直到图片加载完成时替换图片,这样就不会出现闪烁
),
activeIcon: Image.asset(
"assets/image/icon/${iconName}_selected.png",
width: _activeIcon,
height: _activeIcon,
gaplessPlayback: true,
),
);
}
目前界面截图如下
与原图多多少少会有一些差异,后续再做调整
3、Home页面
3.1 顶部导航栏
home_content.dart
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'initialize_item.dart';
class HYHomeContent extends StatefulWidget {
@override
State<HYHomeContent> createState() => _HYHomeContentState();
}
class _HYHomeContentState extends State<HYHomeContent> {
final String userLogo =
"https://i1.hdslb.com/bfs/face/50ca9a7c8c8f11a007510c0e0a7eaea1c8167c54.jpg@240w_240h_1c_1s.webp";
@override
Widget build(BuildContext context) {
return DefaultTabController(
//DefaultTabController用于tabbar和tabbarView
length: tabTitle.length, //设置几个tabBarItem
child: NestedScrollView(
//上划
headerSliverBuilder: (ctx, innerBoxIsScrolled) {
return [
SliverAppBar(
leading: buildHomeUserIcon(userLogo),
title: buildHomeSearch(),
actions: buildHomeActions(),
pinned: false,
),
SliverAppBar(
title: buildHomeTabBar(),
pinned: true,
),
];
},
body: buildHomeTabBarView()));
}
}
//圆形图标
Widget buildHomeUserIcon(String userLogo) {
return Container(
alignment: Alignment.centerRight,
child: CircleAvatar(
backgroundImage: NetworkImage(userLogo),
),
);
}
//搜索
Widget buildHomeSearch() {
return Row(
children: [
Expanded(
child: Container(
alignment: Alignment.centerLeft,
child: Container(
padding: EdgeInsets.only(left: 18.px, top: 10.px, bottom: 10.px),
child: Image.asset("assets/image/icon/search_custom.png")),
height: 35.px,
decoration: BoxDecoration( //圆角
color: Color.fromRGBO(242, 243, 245, 1),
borderRadius: BorderRadius.circular(180.px)),
),
),
],
);
}
List<Widget> buildHomeActions() {
return [
IconButton(
onPressed: () => print("game"),
icon: Image.asset(
"assets/image/icon/game_custom.png",
width: iconSize,
height: iconSize,
)),
IconButton(
onPressed: () => print("more"),
icon: Image.asset(
"assets/image/icon/mail_custom.png",
width: iconSize,
height: iconSize,
)),
];
}
//直播、推荐那个几个item的tabbar
TabBar buildHomeTabBar() {
return TabBar(
tabs: tabTitle.map((e) => Tab(text: e)).toList(),
indicatorColor: Color.fromRGBO(253, 105, 155, 1),
unselectedLabelColor: Color.fromRGBO(95, 95, 95, 1),
labelColor: Color.fromRGBO(253, 105, 155, 1),
indicatorSize: TabBarIndicatorSize.label,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.normal),
indicatorWeight: 4.px,
labelPadding: EdgeInsets.zero,
indicatorPadding: EdgeInsets.only(bottom: 10.px),
);
}
//home中主要显示的内容,与tabBar对应
Widget buildHomeTabBarView() {
return TabBarView(
children: tabTitle.map((e) {
return ListView.builder(
itemBuilder: (ctx, index) {
return Text("123");
},
itemCount: 50,
);
}).toList(),
);
}
home.dart
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'home_content.dart';
import 'initialize_item.dart';
class HYHomeScreen extends StatelessWidget {
static const String routeName = "/home";
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea( //防止被遮挡
child: HYHomeContent()),
);
}
}
运行截图
3.2 tabBarView
Widget buildHomeTabBarView() {
return TabBarView(
children: tabTitle.map((e) {
if (e == "直播") {
return HYHomeRecommendScreen();
} else if (e == "推荐") {
return HYHomeRecommendScreen();
} else if (e == "动画") {
return HYHomeRecommendScreen();
} else if (e == "影视") {
return HYHomeRecommendScreen();
} else {
return HYHomeRecommendScreen();
}
}).toList(),
);
}
这里先对推荐界面进行构建,后面再更改
3.3 主页推荐界面
对于推荐界面,原本的APP,推荐的视频包括多种类型,这里我进行简化,选择一种类型的视频布局进行展示。上拉刷新和下拉刷新需要用到共享数据,所以引入provider。
3.3.1 创建共享数据provider
main.dart
main() {
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(create: (ctx) => HYBaseDataViewModel()),
ChangeNotifierProxyProvider<HYBaseDataViewModel, HYVideoViewModel>(
create: (cts) => HYVideoViewModel(),
update: (ctx, baseDataVM, videoVM) {
videoVM?.updateBaseData(baseDataVM);
return videoVM as HYVideoViewModel;
})
],
child: MyApp(),
));
}
import 'package:flutter/material.dart';
class HYBaseDataViewModel extends ChangeNotifier {
int _rid = 1; //分区
int _pn = 1; //页数
int _ps = 11; //每页项数
int get ps => _ps;
set ps(int value) {
_ps = value;
notifyListeners();
}
int get pn => _pn;
set pn(int value) {
_pn = value;
notifyListeners();
}
int get rid => _rid;
set rid(int value) {
_rid = value;
notifyListeners();
}
}
import 'package:flutter/cupertino.dart';
import 'package:flutter_bilibili/core/model/video_model.dart';
import 'package:flutter_bilibili/core/viewmodel/base_data_view_model.dart';
import '../service/request/home_request.dart';
class HYVideoViewModel extends ChangeNotifier {
List<HYVideoModel> _videos = [];
HYBaseDataViewModel _baseDataVM = HYBaseDataViewModel();
List<HYVideoModel> get videos => _videos;
set videos(List<HYVideoModel> value) {
_videos = value;
}
//更新basedata
void updateBaseData(HYBaseDataViewModel baseDataVM) {
_baseDataVM = baseDataVM;
}
HYVideoViewModel() {
//请求数据
HYHomeRequest.getVideoData(_baseDataVM.rid, _baseDataVM.pn, _baseDataVM.ps)
.then((res) {
_videos = res;
notifyListeners();
});
}
HYBaseDataViewModel get baseDataVM => _baseDataVM;
}
HYVideoViewModel 要用到HYBaseDataViewModel 的数据,将两者关联起来用ChangeNotifierProxyProvider来做
3.3.2 网络请求数据
数据主要来源于这个网站的API
引入之前就写好的http_request
创建新的请求
home_request.dart
import '../../model/video_model.dart';
import '../utils/http_request.dart';
class HYHomeRequest {
static Future<List<HYVideoModel>> getVideoData(int rid, int pn, int ps) async {
/**
* rid为分区编号,必填
* pn为页数
* ps为一页几项video数据
*/
final url = "?rid=$rid&pn=$pn&ps=$ps";
print(url);
final result = await HttpRequest.request(url);
final videoArray = result["data"]["archives"];
List<HYVideoModel> videos = [];
for (var json in videoArray) {
videos.add(HYVideoModel.fromJson(json));
}
return videos;
}
}
json数据样例
{
"aid": 683521911,
"videos": 1,
"tid": 47,
"tname": "短片·手书·配音",
"copyright": 1,
"pic": "http://i2.hdslb.com/bfs/archive/ff06271f11b5226864b6af1709d38f07d996cbf4.jpg",
"title": "当男生听见对象喊自己老公",
"pubdate": 1651225773,
"ctime": 1651225773,
"desc": "当男生听到对象喊自己老公...\n你的对象是不是也是这样?",
"state": 0,
"duration": 23,
"rights": {
"bp": 0,
"elec": 0,
"download": 0,
"movie": 0,
"pay": 0,
"hd5": 1,
"no_reprint": 1,
"autoplay": 1,
"ugc_pay": 0,
"is_cooperation": 0,
"ugc_pay_preview": 0,
"no_background": 0,
"arc_pay": 0,
"pay_free_watch": 0
},
"owner": {
"mid": 1590972136,
"name": "咕咕吖吖GuGuYaYa",
"face": "http://i1.hdslb.com/bfs/face/0d2b6016c73c33b90dabaa62ab0d119db371eb4f.jpg"
},
"stat": {
"aid": 683521911,
"view": 8017,
"danmaku": 0,
"reply": 10,
"favorite": 61,
"coin": 16,
"share": 343,
"now_rank": 0,
"his_rank": 0,
"like": 107,
"dislike": 0
},
"dynamic": "",
"cid": 587926709,
"dimension": {
"width": 1080,
"height": 1920,
"rotate": 0
},
"season_id": 424827,
"short_link": "https://b23.tv/BV1XS4y1c7f5",
"short_link_v2": "https://b23.tv/BV1XS4y1c7f5",
"first_frame": "http://i0.hdslb.com/bfs/storyff/n220429qn10se0ljs4qjv6c637rxe6kq_firsti.jpg",
"bvid": "BV1XS4y1c7f5",
"season_type": 0,
"is_ogv": false,
"ogv_info": null,
"rcmd_reason": ""
}
放到转对象的网站去解析,对时长duration字段进行加工,新增一个durationText。目的是将多少秒的时长转成小时:分钟:秒格式的文本
durationText: changeToDurationText((json["duration"] as int).toDouble()), //初始化数值
String changeToDurationText(double duration) {
if(duration > 60) {
if(duration > 3600) {
var hours = duration ~/ 3600;
var minutes = (duration - hours * 3600) ~/ 60;
var seconds = (duration - hours * 3600 - minutes * 60).toInt();
return hours.toString() + minutes.toString().padLeft(2, '0') + seconds.toString().padLeft(2, '0');
}else{
var minutes = duration ~/ 60;
var seconds = (duration - minutes * 60).toInt();
return minutes.toString() + ":" + seconds.toString().padLeft(2, '0');
}
}else{
return "0:" + duration.toInt().toString().padLeft(2, '0');
}
}
3.3.3 界面布局
home_recommend.dart
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/core/model/video_model.dart';
import 'package:flutter_bilibili/core/viewmodel/video_view_model.dart';
import 'package:flutter_bilibili/ui/pages/home/home_video_item.dart';
import 'package:flutter_easyrefresh/easy_refresh.dart';
import 'package:flutter_swiper_null_safety/flutter_swiper_null_safety.dart';
import 'package:provider/provider.dart';
import 'home_refresh_item.dart';
import 'load_more_videos_data.dart';
class HYHomeRecommendScreen extends StatefulWidget {
@override
State<HYHomeRecommendScreen> createState() => _HYHomeRecommendScreenState();
}
class _HYHomeRecommendScreenState extends State<HYHomeRecommendScreen> {
List<Widget> widgets = [];
@override
Widget build(BuildContext context) {
return Consumer<HYVideoViewModel>(
builder: (ctx, videoVM, child) {
if (videoVM.videos.isEmpty) {
return Center(
child: Text("网络故障"),
);
}
if(widgets.isEmpty){
widgets.addAll([
buildHomeRecommendCarousel(videoVM.videos.sublist(0, 3)),
buildHomeRecommendVideoCards(videoVM.videos.sublist(3)),
]);
}
return EasyRefresh(
onRefresh: () async {
refreshVideosData(videoVM); //耗时操作放前面,3秒内加载数据
await Future.delayed(Duration(seconds: 3)).then((value) {
setState(() {
videoVM.baseDataVM.pn++;
widgets.insert(0, HYHomeRefreshItem(0, videoVM.videos)); //需等待数据再执行
});
});
},
onLoad: () async {
loadMoreVideosData(videoVM);
await Future.delayed(Duration(seconds: 3)).then((value) {
setState(() {
videoVM.baseDataVM.pn++;
widgets.add(HYHomeRefreshItem(1, videoVM.videos));
});
});
},
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.only(
left: 8.px, right: 8.px, top: 4.px, bottom: 0),
child: Column(
children: widgets,
),
),
),
);
},
);
}
//轮播图
Widget buildHomeRecommendCarousel(List<HYVideoModel> data) {
return ClipRRect(
borderRadius: BorderRadius.circular(4.px),
child: Container(
margin: EdgeInsets.only(bottom: 8.px),
height: 190.px, //这里的轮播图组件必须包裹在有高度的控件或者设置比例
child: Swiper(
itemBuilder: (ctx, index) {
return Image.network(
data[index].pic,
fit: BoxFit.fill,
);
},
itemCount: data.length,
indicatorLayout: PageIndicatorLayout.SCALE,
autoplayDelay: 3000,
pagination: SwiperPagination(
alignment: Alignment.bottomRight,
margin:
EdgeInsets.only(left: 0, right: 8.px, bottom: 8.px, top: 0)),
fade: 1.0,
autoplay: true,
scrollDirection: Axis.horizontal,
),
),
);
}
Widget buildHomeRecommendVideoCards(List<HYVideoModel> data) {
return GridView.builder(
/**
* 这里的shrinkWrap和physics必须设置,
* 搭配SingleChildScrollView和column一起使用
*/
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: data.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, childAspectRatio: 0.9 //这里的比例设置了,子widget高度属性就无效果了
),
itemBuilder: (ctx, index) {
return HYHomeVideoItem(data[index]);
},
);
}
Widget buildHomeRecommendOneVideo() {
return Center(
child: Text("data"),
);
}
}
这里的刷新我构建了widgets,当有刷新的动作时,给这个数组增加新的widgt。以及更新viewmodel里面的video_view_model和basedata_view_model。为了获得不同的布局(b站刷新出来可能是一个大视频在前,10个小视频在后,也可能是顺序相反),这里random2就是随机数字来控制产生的布局不同。
home_refresh_item.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/core/model/video_model.dart';
import '../../../core/viewmodel/video_view_model.dart';
import 'home_video_item.dart';
class HYHomeRefreshItem extends StatefulWidget {
int mode; //上拉还是下拉
List<HYVideoModel> videos = [];
HYHomeRefreshItem(this.mode, this.videos);
@override
State<HYHomeRefreshItem> createState() => _HYHomeRefreshItemState();
}
//上拉刷新的出来布局
class _HYHomeRefreshItemState extends State<HYHomeRefreshItem> {
@override
Widget build(BuildContext context) {
//上拉
if(widget.mode == 0) {
return Column(
children: random2(
buildHYHomeRefreshItemVideos(widget.videos.sublist(0, 10)),
buildHYHomeRefreshItemOneVideo(widget.videos[10])),
);
}else {
//下拉
return Column(
children: random2(
buildHYHomeRefreshItemVideos(widget.videos.sublist(widget.videos.length-11, widget.videos.length-1)),
buildHYHomeRefreshItemOneVideo(widget.videos[widget.videos.length-1])),
);
}
}
Widget buildHYHomeRefreshItemVideos(List<HYVideoModel> videos) {
return GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: videos.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, childAspectRatio: 0.9
),
itemBuilder: (ctx, index) {
return HYHomeVideoItem(videos[index]);
},
);
}
Widget buildHYHomeRefreshItemOneVideo(HYVideoModel video) {
return ClipRRect(
borderRadius: BorderRadius.circular(4.px),
child: Container(
margin: EdgeInsets.symmetric(vertical: 8.px),
height: 190.px,
width: double.infinity,
child: Image.network(
video.pic,
fit: BoxFit.fill,
),
),
);
}
//随机布局,如果是0就A在前B在后;反之则B在前,A在后
List<Widget> random2(Widget widgetA, Widget widgetB) {
int randomNum = Random().nextInt(2);
if (randomNum == 0) {
return [widgetA, widgetB];
}
return [widgetB, widgetA];
}
}
数据加载进来后,需要更新共享数据video_view_model中的videos
load_more_videos_data.dart
import 'package:flutter_bilibili/core/service/request/home_request.dart';
import '../../../core/model/video_model.dart';
import '../../../core/viewmodel/video_view_model.dart';
void loadMoreVideosData(HYVideoViewModel videoVM) {
//下拉请求数据
HYHomeRequest.getVideoData(videoVM.baseDataVM.rid, videoVM.baseDataVM.pn, videoVM.baseDataVM.ps).then((res) {
videoVM.videos.addAll(res);
});
}
void refreshVideosData(HYVideoViewModel videoVM) {
//上拉请求数据
HYHomeRequest.getVideoData(videoVM.baseDataVM.rid, videoVM.baseDataVM.pn, videoVM.baseDataVM.ps).then((res) {
videoVM.videos = videoVM.videos.reversed.toList();
videoVM.videos.addAll(res);
videoVM.videos = videoVM.videos.reversed.toList();
});
}
最后是一行两个视频的单个视频布局文件
home_video_item.dart
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/core/model/video_model.dart';
import 'package:flutter_bilibili/ui/shared/app_theme.dart';
import 'package:flutter_bilibili/ui/shared/number_compute.dart';
final _radius = 6.px;
final _iconSize = 14.px;
class HYHomeVideoItem extends StatefulWidget {
HYVideoModel _video;
HYHomeVideoItem(this._video);
@override
State<HYHomeVideoItem> createState() => _HYHomeVideoItemState();
}
class _HYHomeVideoItemState extends State<HYHomeVideoItem> {
@override
Widget build(BuildContext context) {
return Stack(
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(_radius),
topRight: Radius.circular(_radius),
),
),
child: Column(
children: [
Stack(
children: [
buildHomeVideoItemCover(widget._video),
buildHomeVideoItemInfo(widget._video, context),
buildHomeVideoItemDuration(widget._video.durationText)
],
),
buildHomeVideoItemTitle(context, widget._video.title),
],
),
),
buildHomeVideoBottomInfo(context, widget._video.owner.name),
buildHomeVideoMoreIcon()
],
);
}
}
//更多按钮
class buildHomeVideoMoreIcon extends StatelessWidget {
const buildHomeVideoMoreIcon({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
child: Image.asset(
"assets/image/icon/video_more_custom.png",
width: _iconSize,
height: _iconSize,
),
right: 8.px,
bottom: 8.px,
);
}
}
//视频封面
Widget buildHomeVideoItemCover(HYVideoModel video) {
return ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(_radius),
topRight: Radius.circular(_radius),
),
child: Image.network(
video.pic,
width: double.infinity,
height: 110.px,
fit: BoxFit.fill,
),
);
}
//视频时长
Widget buildHomeVideoItemDuration(String duration) {
return Positioned(
right: 5.px,
bottom: 3.px,
child: Text(duration,
style: TextStyle(
color: Color.fromRGBO(255, 255, 255, 1),
fontSize: HYAppTheme.xxSmallFontSize)),
);
}
//视频播放量、评论数
Widget buildHomeVideoItemInfo(HYVideoModel video, BuildContext context) {
int? _view = video.stat["view"];
int? _remark = video.stat["danmaku"];
return Positioned(
left: 5.px,
bottom: 3.px,
child: Row(
children: [
buildHomeVideoIconInfoItem(
"assets/image/icon/play_custom.png", _view!, context),
SizedBox(
width: 10.px,
),
buildHomeVideoIconInfoItem(
"assets/image/icon/remark.png", _remark!, context),
],
),
);
}
//视频的标题
Widget buildHomeVideoItemTitle(BuildContext context, String videoTitle) {
return Container(
alignment: Alignment.topLeft,
margin: EdgeInsets.symmetric(vertical: 8.px, horizontal: 8.px),
child: Text(
videoTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.black, fontSize: 13.px),
),
);
}
//视频up主及id名称
Widget buildHomeVideoBottomInfo(BuildContext context, String info) {
return Positioned(
bottom: 8.px,
left: 8.px,
child: Row(
// mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Image.asset(
"assets/image/icon/uper_custom.png",
width: _iconSize,
height: _iconSize,
),
Container(
margin: EdgeInsets.only(left: 6.px),
child: Text(info,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Color.fromRGBO(149, 149, 149, 1))),
),
],
),
);
}
//视频播放量和评论如果过万,就要显示多少万
Widget buildHomeVideoIconInfoItem(String icon, int num, BuildContext context) {
double _numDiv = num.toDouble();
int _flag = 0;
if (num > 10000) {
_numDiv = num / 10000;
_flag = 1;
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
icon,
width: 13.px,
height: 13.px,
),
Container(
alignment: Alignment.centerLeft,
margin: EdgeInsets.only(left: 5.px),
child: Text(
_flag == 1 ? formatNum(_numDiv, 1) + "万" : formatNum(_numDiv, -1),
style: TextStyle(
color: Color.fromRGBO(255, 255, 255, 1),
fontSize: HYAppTheme.xxSmallFontSize)),
)
],
);
}
3.3.4 刷新功能
刷新功能引入flutter_easyrefresh来做
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
flutter_swiper_null_safety: ^1.0.2
provider: ^6.0.2
dio: ^4.0.6
flutter_easyrefresh: ^2.2.1
3.4 主页推荐界面展示 >-<
目前界面大致是这样,上拉刷新,下拉加载,首先是轮播图3条视频加8个小一点的视频;再是上拉和下拉加载1张大图和10张小图,也是11条数据。
4、视频界面(部分)
4.1 导入播放视频的插件
fijkplayer: ^0.10.1
video_player: ^2.2.7
这里的视频URL我没有采用前面视频json传来的数据,是有API接口提供原视频MAP4格式的链接,但是430。所以我往videoModel加入了VideoData对象,提前写入一些视频,获取对象时就随机一个视频。
4.2 视频数据
video_model.dart
/**
* type=1:长视频;type=0:宽视频
*/
//视频数据
class VideoData {
String videoURL; //视频url
int videoHeightType;//视频类型
VideoData(this.videoURL, this.videoHeightType);
}
List<VideoData> videoList = [
VideoData('test-video-10.MP4', 0),
VideoData('test-video-6.mp4', 1),
VideoData('test-video-9.MP4', 0),
VideoData('test-video-8.MP4', 1),
VideoData('test-video-7.MP4', 0),
VideoData('test-video-1.mp4', 0),
VideoData('test-video-2.mp4', 1),
VideoData('test-video-3.mp4', 1),
VideoData('test-video-4.mp4', 1),
];
VideoData randomGetVideo() {
int randomNum = Random().nextInt(videoList.length);
return videoList[randomNum];
// return "https://static.ybhospital.net/"+videoList[randomNum].url;
}
4.3 使用插件fijkplayer
video_play_content.dart
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/core/model/video_model.dart';
import 'package:flutter_bilibili/ui/pages/video_play/video_play_comments.dart';
import 'package:flutter_bilibili/ui/pages/video_play/video_play_initialize_item.dart';
import 'package:flutter_bilibili/ui/pages/video_play/video_play_profile.dart';
import 'package:flutter_bilibili/ui/shared/app_theme.dart';
import '../../widgets/bilibiliFijkPanel.dart';
class HYVideoPlayContent extends StatefulWidget {
HYVideoModel video;
HYVideoPlayContent(this.video);
@override
State<HYVideoPlayContent> createState() => _HYVideoPlayContentState();
}
class _HYVideoPlayContentState extends State<HYVideoPlayContent> {
var tabTitle = ['简介', '评论'];
final FijkPlayer player = FijkPlayer();
@override
void initState() {
super.initState();
player.setDataSource(
"https://static.ybhospital.net/" + widget.video.videoData.videoURL,
// autoPlay: true,
showCover: true);
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: tabTitle.length,
child: NestedScrollView(
headerSliverBuilder: (ctx, innerBoxIsScrolled) {
return [
SliverAppBar(
backgroundColor: Color.fromRGBO(253, 105, 155, 1),
pinned: true,
toolbarHeight: 70.px,
expandedHeight:
widget.video.videoData.videoHeightType == 0 ? 280.px : 600.px,
//长视频和短视频采用不同的高度
collapsedHeight: 100.px,
//收缩后的高度
leading: IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: Image.asset(
"assets/image/icon/back_custom.png",
width: iconSize,
height: iconSize,
)),
actions: buildVideoPlayActions(),
flexibleSpace: buildVideoPlayVideoPlayer(),
bottom: buildVideoPlayTabBar(context),
),
];
},
body: buildVideoPlayTabBarView(),
),
initialIndex: 0,
);
}
@override
void dispose() {
super.dispose();
player.release();
}
//home中主要显示的内容,与tabBar对应
Widget buildVideoPlayTabBarView() {
return TabBarView(
children: tabTitle.map((e) {
if (e == "简介") {
return HYVideoPlayProfile();
} else {
return HYVideoPlayComments();
}
}).toList(),
);
}
PreferredSizeWidget buildVideoPlayTabBar(BuildContext context) {
return PreferredSize(
//tab设置底色
preferredSize: Size.fromHeight(20),
child: Material(
color: Colors.white,
child: TabBar(
tabs: tabTitle.map((e) => Tab(text: e)).toList(),
indicatorColor: Color.fromRGBO(253, 105, 155, 1),
unselectedLabelColor: Color.fromRGBO(95, 95, 95, 1),
labelColor: Color.fromRGBO(253, 105, 155, 1),
indicatorSize: TabBarIndicatorSize.label,
labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: HYAppTheme.xxSmallFontSize),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: HYAppTheme.xxSmallFontSize),
indicatorWeight: 4.px,
labelPadding: EdgeInsets.zero,
indicatorPadding: EdgeInsets.only(bottom: 10.px),
),
));
}
List<Widget> buildVideoPlayActions() {
return [
IconButton(
onPressed: () => print("mini_window_custom"),
icon: Image.asset(
"assets/image/icon/mini_window_custom.png",
width: iconSize,
height: iconSize,
)),
IconButton(
onPressed: () => print("tv_play_custom"),
icon: Image.asset(
"assets/image/icon/tv_play_custom.png",
width: iconSize,
height: iconSize,
)),
IconButton(
onPressed: () => print("video_player_more_custom"),
icon: Image.asset(
"assets/image/icon/video_player_more_custom.png",
width: iconSize,
height: iconSize,
)),
];
}
Widget buildVideoPlayVideoPlayer() {
return FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.only(bottom: 48.px),
child: FijkView(
color: Colors.black,
player: player,
fit: widget.video.videoData.videoHeightType == 0 ? FijkFit.fill : FijkFit.contain,
panelBuilder: (player, data, ctx, viewSize, texturePos) {
return BilibiliFijkPanel(
player: player,
buildContext: ctx,
viewSize: viewSize,
texturePos: texturePos);
},
),
),
);
}
}
4.4 视频进度条样式及视频按钮
这里的bilibiliFijkPanel,我是参考了FijkPanel提供的默认的进度条。
bilibiliFijkPanel.dart
import 'dart:async';
import 'dart:math';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
class BilibiliFijkPanel extends StatefulWidget {
final FijkPlayer player;
final BuildContext buildContext;
final Size viewSize;
final Rect texturePos;
const BilibiliFijkPanel(
{required this.player,
required this.buildContext,
required this.viewSize,
required this.texturePos});
@override
State<BilibiliFijkPanel> createState() => _BilibiliFijkPanelState();
}
String _duration2String(Duration duration) {
if (duration.inMilliseconds < 0) return "-: negtive";
String twoDigits(int n) {
if (n >= 10) return "$n";
return "0$n";
}
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
int inHours = duration.inHours;
return inHours > 0
? "$inHours:$twoDigitMinutes:$twoDigitSeconds"
: "$twoDigitMinutes:$twoDigitSeconds";
}
class _BilibiliFijkPanelState extends State<BilibiliFijkPanel> {
FijkPlayer get player => widget.player;
Duration _duration = Duration();
Duration _currentPos = Duration();
Duration _bufferPos = Duration();
bool _playing = false;
bool _prepared = false;
String? _exception;
double _seekPos = -1.0;
StreamSubscription? _currentPosSubs;
StreamSubscription? _bufferPosSubs;
//StreamSubscription _bufferingSubs;
Timer? _hideTimer;
bool _hideStuff = true;
double _volume = 1.0;
final barHeight = 40.0;
@override
void initState() {
super.initState();
_duration = player.value.duration;
_currentPos = player.currentPos;
_bufferPos = player.bufferPos;
_prepared = player.state.index >= FijkState.prepared.index;
_playing = player.state == FijkState.started;
_exception = player.value.exception.message;
// _buffering = player.isBuffering;
player.addListener(_playerValueChanged);
_currentPosSubs = player.onCurrentPosUpdate.listen((v) {
setState(() {
_currentPos = v;
});
});
_bufferPosSubs = player.onBufferPosUpdate.listen((v) {
setState(() {
_bufferPos = v;
});
});
}
@override
Widget build(BuildContext context) {
Rect rect = player.value.fullScreen
? Rect.fromLTWH(0, 0, widget.viewSize.width, widget.viewSize.height)
: Rect.fromLTRB(
max(0.0, widget.texturePos.left),
max(0.0, widget.texturePos.top),
min(widget.viewSize.width, widget.texturePos.right),
min(widget.viewSize.height, widget.texturePos.bottom));
return Positioned.fromRect(
rect: rect,
child: GestureDetector(
onTap: _cancelAndRestartTimer,
child: AbsorbPointer(
absorbing: _hideStuff,
child: Column(
children: <Widget>[
Container(height: barHeight),
Expanded(
child: GestureDetector(
onTap: () {
_cancelAndRestartTimer();
},
child: Container(
color: Colors.transparent,
height: double.infinity,
width: double.infinity,
child: Center(
child: _exception != null
? Text(
_exception!,
style: TextStyle(
color: Colors.white,
fontSize: 25.px,
),
)
: (_prepared ||
player.state == FijkState.initialized)
? GestureDetector(
child: Center(
child: _playing
? const Center()
: Image.asset(
"assets/image/icon/play_video_custom.png",
width: 40.px,
height: 40.px,
),
),
onTap: _playOrPause,
)
: SizedBox(
width: barHeight * 1.5,
height: barHeight * 1.5,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation(
Colors.white)),
)),
),
),
),
_buildBottomBar(context),
],
),
),
),
);
}
@override
void dispose() {
super.dispose();
_hideTimer?.cancel();
player.removeListener(_playerValueChanged);
_currentPosSubs?.cancel();
_bufferPosSubs?.cancel();
}
void _playerValueChanged() {
FijkValue value = player.value;
if (value.duration != _duration) {
setState(() {
_duration = value.duration;
});
}
bool playing = (value.state == FijkState.started);
bool prepared = value.prepared;
String? exception = value.exception.message;
if (playing != _playing ||
prepared != _prepared ||
exception != _exception) {
setState(() {
_playing = playing;
_prepared = prepared;
_exception = exception;
});
}
}
void _playOrPause() {
if (_playing == true) {
player.pause();
} else {
player.start();
}
}
void _startHideTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
setState(() {
_hideStuff = true;
});
});
}
void _cancelAndRestartTimer() {
if (_hideStuff == true) {
_startHideTimer();
}
setState(() {
_hideStuff = !_hideStuff;
});
}
AnimatedOpacity _buildBottomBar(BuildContext context) {
double duration = _duration.inMilliseconds.toDouble();
double currentValue =
_seekPos > 0 ? _seekPos : _currentPos.inMilliseconds.toDouble();
currentValue = min(currentValue, duration);
currentValue = max(currentValue, 0);
return AnimatedOpacity(
opacity: _hideStuff ? 0.0 : 0.8,
duration: Duration(milliseconds: 400),
child: Container(
height: barHeight,
color: Colors.transparent,
child: Row(
children: <Widget>[
GestureDetector(
child: Container(
margin: EdgeInsets.only(left: 8.px),
child: Icon(
_playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: Colors.white,
size: 40.px,
),
),
onTap: _playOrPause,
),
_duration.inMilliseconds == 0
? Expanded(child: Center())
: Expanded(
child: Container(
margin: EdgeInsets.only(left: 12.px, right: 8.px),
child: FijkSlider(
colors: FijkSliderColors(
playedColor: Color.fromRGBO(253, 105, 155, 1),
bufferedColor: Color.fromRGBO(209, 214, 214, 1),
baselineColor: Color.fromRGBO(141, 148, 156, 1),
cursorColor: Color.fromRGBO(253, 105, 155, 1),
),
value: currentValue,
cacheValue: _bufferPos.inMilliseconds.toDouble(),
min: 0.0,
max: duration,
onChanged: (v) {
_startHideTimer();
setState(() {
_seekPos = v;
});
},
onChangeEnd: (v) {
setState(() {
player.seekTo(v.toInt());
_currentPos =
Duration(milliseconds: _seekPos.toInt());
_seekPos = -1;
});
},
),
),
),
// duration / position
_duration.inMilliseconds == 0
? Container(child: const Text("LIVE"))
: Row(
children: [
Padding(
padding: EdgeInsets.only(left: 5.px),
child: Text(
'${_duration2String(_currentPos)}/',
style: TextStyle(fontSize: 14.0, color: Colors.white),
),
),
Text(
'${_duration2String(_duration)}',
style: TextStyle(fontSize: 14.0, color: Colors.white),
),
],
),
IconButton(
icon: widget.player.value.fullScreen
? Icon(
Icons.fullscreen_exit,
size: 25.px,
)
: Image.asset(
"assets/image/icon/full_custom.png",
width: 16.px,
height: 16.px,
),
padding: EdgeInsets.only(left: 10.0, right: 10.0),
// color: Colors.transparent,
onPressed: () {
widget.player.value.fullScreen
? player.exitFullScreen()
: player.enterFullScreen();
},
)
//
],
),
),
);
}
}
我在defaultFijkPanelBuilder上进行修改。
4.5 视频界面视频播放部分的界面展示
4.6 视频简介
视频简介包括很多的信息,以及下面的视频推荐,加起来是4条请求
import 'package:flutter_bilibili/core/model/relation_stat_model_model.dart';
import 'package:flutter_bilibili/core/model/space_nav_num_model.dart';
import 'package:flutter_bilibili/core/model/tag_archive_tags_model.dart';
import 'package:flutter_bilibili/core/model/video_model.dart';
import '../../model/archive_related_model.dart';
import '../utils/http_request.dart';
class HYVideoRequestRequest {
static Future<HYRelationStatModel> getRelationStatData(int mid) async {
final url = "/relation/stat?vmid=$mid&jsonp=jsonp";
final result = await HttpRequest.request(url);
return HYRelationStatModel.fromJson(result);
}
static Future<HYSpaceNavNumModel> getSpaceNavNumData(int mid) async {
final url = "/space/navnum?mid=$mid";
final result = await HttpRequest.request(url);
return HYSpaceNavNumModel.fromJson(result);
}
static Future<List<HYTagArchiveTagsModel>> getTagArchiveTagsData(int aid) async {
final url = "/tag/archive/tags?aid=$aid";
final result = await HttpRequest.request(url);
final tagArray = result["data"];
final List<HYTagArchiveTagsModel> tags = [];
for(var json in tagArray) {
tags.add(HYTagArchiveTagsModel.fromJson(json));
}
return tags;
}
static Future<List<HYVideoModel>> getArchiveRelatedData(int aid) async {
final url = "/web-interface/archive/related?aid=$aid";
final result = await HttpRequest.request(url);
final relatedVideoArray = result["data"];
final List<HYVideoModel> relatedVideos = [];
for(var json in relatedVideoArray) {
relatedVideos.add(HYVideoModel.fromJson(json));
}
return relatedVideos;
}
}
对应的把model也建起来,代码我省了,github上反正有的
至于下面的视频推荐,暂时先建一个item用于构建每一个视频的布局,但是代码内容几乎和home主页的videoitem没差
最后是主题的样式稍微修改了一下
一些通用的函数放到了math_compute.dart中
String formatNum(double num,int position){
if((num.toString().length-num.toString().lastIndexOf(".")-1)<position){
//小数点后有几位小数
return num.toStringAsFixed(position).substring(0,num.toString().lastIndexOf(".")+position+1).toString();
}else{
return num.toString().substring(0,num.toString().lastIndexOf(".")+position+1).toString();
}
}
String changeToWan(int num) {
return num.toDouble() > 10000
? formatNum(num.toDouble() / 10000, 1) + "万"
: formatNum(num.toDouble(), -1);
}
String getPubDataText(int duration) {
var startDate = DateTime(1970, 1, 1, 0, 0, 0);
var endData = startDate.add(Duration(seconds: duration.toInt()));
var endDataText = endData.toString();
return endData.toString().substring(0, endDataText.length - 4);
}
String changeToDurationText(double duration) {
if(duration > 60) {
if(duration > 3600) {
var hours = duration ~/ 3600;
var minutes = (duration - hours * 3600) ~/ 60;
var seconds = (duration - hours * 3600 - minutes * 60).toInt();
return hours.toString() + minutes.toString().padLeft(2, '0') + seconds.toString().padLeft(2, '0');
}else{
var minutes = duration ~/ 60;
var seconds = (duration - minutes * 60).toInt();
return minutes.toString() + ":" + seconds.toString().padLeft(2, '0');
}
}else{
return "0:" + duration.toInt().toString().padLeft(2, '0');
}
}
5、直播界面
5月25日,学了一下Bloc和Stream,这部分的功能就用这两个管理,会与原来的项目结构有别。
5.1、创建bloc文件夹,及初始化
6、登录界面
文本转行(Text.rich)
地区选择(SimpleDialog)
核心代码就上面那个文件夹中了
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/ui/shared/app_theme.dart';
import '../../widgets/rectangle_checkBox.dart';
//用户协议、隐私政策、寻求帮助
class buildLoginAgreement extends StatefulWidget {
const buildLoginAgreement({Key? key}) : super(key: key);
@override
State<buildLoginAgreement> createState() => _buildLoginAgreementState();
}
class _buildLoginAgreementState extends State<buildLoginAgreement> {
var flag = true;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 15.px),
child: Column(
children: [
GestureDetector( //checkbox
onTap: () {
setState(() {
flag = !flag;
});
},
child: Text.rich( //实现换行
TextSpan(
children: [
WidgetSpan(
child: RectangleCheckBox( //自定义矩形的checkbox
size: 15.px,
checkedColor: HYAppTheme.norTextColors,
isChecked: flag,
onTap: (value) {
setState(() {
flag = value!;
});
},
),
),
TextSpan(
text: " 我已经阅读并同意",
style: TextStyle(
color: Colors.grey, fontSize: HYAppTheme.xxSmallFontSize),
),
TextSpan(
text: "用户协议",
style: TextStyle(
color: Colors.blue, fontSize: HYAppTheme.xxSmallFontSize),
),
TextSpan(
text: "和",
style: TextStyle(
color: Colors.grey, fontSize: HYAppTheme.xxSmallFontSize),
),
TextSpan(
text: "隐私政策",
style: TextStyle(
color: Colors.blue, fontSize: HYAppTheme.xxSmallFontSize),
),
TextSpan(
text: ",未注册绑定的手机号验证成功后将自动注册",
style: TextStyle(
color: Colors.grey, fontSize: HYAppTheme.xxSmallFontSize),
),
],
),
),
),
SizedBox(
height: 20.px,
),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "遇到问题?",
style: TextStyle(
color: Colors.grey, fontSize: HYAppTheme.xxSmallFontSize),
),
TextSpan(
text: "查看帮助",
style: TextStyle(
color: Colors.blue, fontSize: HYAppTheme.xxSmallFontSize),
),
],
),
),
],
),
);
}
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bilibili/core/extension/int_extension.dart';
import 'package:flutter_bilibili/ui/pages/login/initialize_login.dart';
import 'package:flutter_bilibili/ui/shared/app_theme.dart';
import 'login_agreement.dart';
class HYLoginContent extends StatelessWidget with InitializeLogin {
@override
Widget build(BuildContext context) {
double screenWidth = MediaQuery.of(context).size.width;
return Scaffold(
appBar: AppBar(
title: const Text(
"手机号注册登录",
style: TextStyle(fontSize: HYAppTheme.xSmallFontSize),
),
actions: const [ ///右上角的密码登录
Center(
child: Text(
"密码登录 ",
style: TextStyle(fontSize: HYAppTheme.xxSmallFontSize),
))
],
),
body: Column(
children: [
buildLoginImage(), ///2233娘背景
GestureDetector(
child: buildLoginRegion(context), ///所属地区
onTap: () {
HYRegionDialog(context);
},
),
buildLoginTel(context), ///电话号码
SizedBox(
height: 15.px,
),
TextButton(
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(HYAppTheme.norTextColors),
minimumSize:
MaterialStateProperty.all(Size(screenWidth - 30.px, 40.px)),
),
onPressed: () {},
child: Text("验证登录"),
),
SizedBox(
height: 15.px,
),
buildLoginAgreement() ///用户协议部分
],
),
resizeToAvoidBottomInset: false, ///防止键盘弹出超出边界
);
}
//图片层
Widget buildLoginImage() {
return Stack(
children: [
Center(
child: Image.asset(
"assets/image/icon/bilibili.png",
width: imageWidth,
height: imageHeight,
)),
Positioned(
child: Image.asset("assets/image/icon/22_open.png"),
width: imageWidth,
height: imageHeight,
left: 0,
bottom: 0,
),
Positioned(
child: Image.asset("assets/image/icon/33_open.png"),
width: imageWidth,
height: imageHeight,
right: 0,
bottom: 0,
),
],
);
}
//地区
Widget buildLoginRegion(BuildContext context) {
return Stack(
children: [
Container(
alignment: Alignment.centerLeft,
child: Text(
list[regionIndex].region,
style: TextStyle(color: Colors.black),
),
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 10.px, horizontal: 15.px),
decoration: BoxDecoration(
color: Colors.white,
border: Border( ///底部设置边框
bottom:
BorderSide(width: 1.px, color: Theme.of(context).canvasColor),
),
),
),
Positioned(
right: 15.px,
top: 0,
bottom: 0,
child: Icon(
Icons.arrow_forward_ios,
size: 15.px,
))
],
);
}
//手机号码/验证码
Widget buildLoginTel(BuildContext context) {
final usernameTextEditController = TextEditingController();
final passwordTextEditController = TextEditingController();
return Container(
color: Colors.white,
width: double.infinity,
child: Column(
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 2.px, horizontal: 15.px),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(
width: 2.px, color: Theme.of(context).canvasColor),
),
),
child: Row(
children: [
Text(list[regionIndex].telNum),
Expanded(
child: Container(
margin: EdgeInsets.only(left: 15.px),
child: TextField(
autofocus: true,
showCursor: true,
cursorColor: HYAppTheme.norTextColors,
controller: usernameTextEditController,
decoration: InputDecoration(
hintText: "请输入手机号码", border: InputBorder.none),
),
),
),
Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
width: 1.px, color: Theme.of(context).canvasColor),
),
),
padding: EdgeInsets.symmetric(horizontal: 10.px),
child: Text("获取验证码"),
),
],
),
),
Container(
padding: EdgeInsets.symmetric(vertical: 5.px, horizontal: 15.px),
child: Row(
children: [
Text("验证码"),
Expanded(
child: Container(
margin: EdgeInsets.only(left: 15.px),
child: TextField(
showCursor: true,
cursorColor: HYAppTheme.norTextColors,
controller: passwordTextEditController,
decoration: InputDecoration(
hintText: "请输入验证码", border: InputBorder.none),
),
),
)
],
),
),
],
),
);
}
HYRegionDialog(BuildContext context) async {
List<Widget> widgets = [];
for (int i = 0; i < list.length; i++) {
widgets.add(TextButton(
onPressed: () {
regionIndex = i;
Navigator.pop(context);
},
child: Text(
list[i].region,
style: TextStyle(color: Colors.black),
),
));
}
var regionDialog = await showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: Text(
"地区",
style: TextStyle(fontSize: HYAppTheme.xSmallFontSize, fontWeight: FontWeight.bold),
),
children: widgets,
);
},
);
return regionDialog;
}
}
7、直播推荐界面
网络请求+gridview+customScrollview+tabBarView+DefaultTabController
核心主要是这块代码
Widget buildLiveRoomList(List<HYLiveRoomModel> data) {
return CustomScrollView(
slivers: [
SliverPadding(
padding: EdgeInsets.only(top: 8.px, left: 8.px, right: 8.px),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, index) {
return buildLiveSwiperCarousel(data.sublist(0, 3));
},
childCount: 1,
),
),
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 8.px),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate((ctx, index) {
return HYLiveRoomItem(data[index]);
}),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, childAspectRatio: 1.0),
),
)
],
);
}
8、视频弹幕
b站的API返回一些xml数据,我请求过来之后乱码,加了请求头,请求类型,还是不行。这个问题我暂时无法解决。所以本地就弄了些数据,但也是xml数据,用了个插件,转成json数据格式。
至于弹幕的显示,移动,必然要用到动画,完成整个过程需要用到计时器、动画、stack定位、key。
一块是弹幕,一块是视频
这里的弹幕实现是这样的,首先弹幕文本样式自己设置,我不讲了,那么弹幕由右至左,显然是动画
import 'package:flutter/material.dart';
class DanMuItem extends StatefulWidget {
String title;
double top;
DanMuItem(this.title, this.top);
@override
State<DanMuItem> createState() => _DanMuItemState();
}
class _DanMuItemState extends State<DanMuItem>
with SingleTickerProviderStateMixin {
late AnimationController controller;
late Animation<Offset> animation;
@override
void initState() {
controller =
AnimationController(duration: Duration(seconds: 12), vsync: this);
controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
// controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.stop();
}
});
animation = Tween(begin: Offset(1.0, .0), end: Offset(-1.0, .0))
.animate(controller);
controller.forward();
super.initState();
}
@override
Widget build(BuildContext context) {
return Positioned.fill( ///这里的fill是指整个stack的position,去掉之后就是相对上一个弹幕
top: widget.top,
child: SlideTransition(
position: animation,
child: Text(
widget.title,
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
那么定位要采用position.fill,即在整个stack容器里定位,而不是弹幕B根据弹幕A去定位,发出弹幕采用定时器,将弹幕一条条都添加到链表中去。如何调用状态里面方法,那么得用key,并将状态改成公有的,而不是私有的。
在videoplay_content用状态里的方法
Widget buildVideoPlayVideoPlayer() {
HYDanMuRequest.getDanMuData().then((value) {
List valueList = [];
valueList.addAll(value);
int index = 0;
int roundNum = 0;
double top = 0;
Timer.periodic(Duration(milliseconds: 500), (timer) {
String text = valueList[index];
if(text.length <= 10) {
_danMuKey.currentState?.addDanMu(valueList[index], top); ///添加弹幕
roundNum++;
if(roundNum%5==0){
top = 0;
}else{
top+=20;
}
}
index++;
if (index == valueList.length) {
timer.cancel();
}
});
});
return FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.only(bottom: 48.px),
child: Stack(
children: [
FijkView(
color: Colors.black,
player: player,
fit: widget.video.videoData.videoHeightType == 0
? FijkFit.fill
: FijkFit.contain,
panelBuilder: (player, data, ctx, viewSize, texturePos) {
return BilibiliFijkPanel(
player: player,
buildContext: ctx,
viewSize: viewSize,
texturePos: texturePos);
},
),
Container(
height: 250.px,
child: DanMu(
key: _danMuKey,
),
),
],
),
),
);
}
9、极光认证
极光认证提供了一个界面的构造器,flutter这边只有部分样式,具体还得去Android原生那边去更改
void loginAuth() {
setState(() {
_showLoading(context);
});
jverify.checkVerifyEnable().then((map) {
bool result = map[f_result_key];
if (result) {
final screenSize = MediaQuery.of(context).size;
final screenWidth = screenSize.width;
final screenHeight = screenSize.height;
bool isiOS = Platform.isIOS;
JVUIConfig uiConfig = JVUIConfig();
uiConfig.logoHidden = true;
uiConfig.numberColor = Colors.black.value;
uiConfig.numberTextBold = true;
uiConfig.numberSize = HYAppTheme.normalFontSize.toInt();
uiConfig.numFieldOffsetY = 70;
uiConfig.sloganHidden = true;
uiConfig.logBtnOffsetY = 110;
uiConfig.logBtnWidth = (screenWidth - 180).toInt();
uiConfig.needStartAnim = true;
uiConfig.needCloseAnim = true;
uiConfig.privacyCheckboxSize = 13;
uiConfig.privacyTextSize = 10;
uiConfig.privacyOffsetX = 10;
uiConfig.privacyTopOffsetY = 190;
uiConfig.privacyItem = [
JVPrivacy("用户协议、隐私政策", "http://www.baidu.com", separator: "、"),
JVPrivacy("中国移动号码认证系统服务协议", "http://www.baidu.com", separator: "、"),
];
uiConfig.privacyUnderlineText = false;
//弹框模式
JVPopViewConfig popViewConfig = JVPopViewConfig();
popViewConfig.width = (screenWidth - 140).toInt();
popViewConfig.height = (screenHeight - 450).toInt();
uiConfig.popViewConfig = popViewConfig;
/// 添加自定义的 控件 到授权界面
List<JVCustomWidget> widgetList = [];
const jVerifyHeaderText = "jv_header_text"; // 标识控件 id
JVCustomWidget jVerifyHeaderTextWidget =
JVCustomWidget(jVerifyHeaderText, JVCustomWidgetType.textView);
jVerifyHeaderTextWidget.title = "登录注册解锁更多内容";
jVerifyHeaderTextWidget.top = 25;
jVerifyHeaderTextWidget.width = screenWidth.toInt();
jVerifyHeaderTextWidget.backgroundColor = Colors.transparent.value;
jVerifyHeaderTextWidget.isShowUnderline = true;
jVerifyHeaderTextWidget.titleFont = HYAppTheme.xSmallFontSize;
jVerifyHeaderTextWidget.textAlignment = JVTextAlignmentType.center;
jVerifyHeaderTextWidget.isShowUnderline = false;
const jVerifyOtherLoginText = "jv_other_login_text"; // 标识控件 id
JVCustomWidget jVerifyOtherLoginTextWidget =
JVCustomWidget(jVerifyOtherLoginText, JVCustomWidgetType.textView);
jVerifyOtherLoginTextWidget.title = "其他登录方式";
jVerifyOtherLoginTextWidget.top = 160;
jVerifyOtherLoginTextWidget.width = screenWidth.toInt();
jVerifyOtherLoginTextWidget.backgroundColor = Colors.transparent.value;
jVerifyOtherLoginTextWidget.titleColor =
const Color.fromRGBO(77, 77, 77, 1).value;
jVerifyOtherLoginTextWidget.isShowUnderline = false;
jVerifyOtherLoginTextWidget.titleFont = HYAppTheme.xSmallFontSize;
jVerifyOtherLoginTextWidget.textAlignment = JVTextAlignmentType.center;
const String jVerifyCloseText = "jv_close_button"; // 标识控件 id
JVCustomWidget jVerifyCloseTextWidget =
JVCustomWidget(jVerifyCloseText, JVCustomWidgetType.button);
jVerifyCloseTextWidget.title = "X";
jVerifyCloseTextWidget.titleColor = HYAppTheme.norGrayColor.value;
jVerifyCloseTextWidget.top = 0;
jVerifyCloseTextWidget.width = screenWidth.toInt();
jVerifyCloseTextWidget.isShowUnderline = false;
jVerifyCloseTextWidget.textAlignment = JVTextAlignmentType.right;
jVerifyCloseTextWidget.backgroundColor = Colors.transparent.value;
widgetList.add(jVerifyHeaderTextWidget);
widgetList.add(jVerifyOtherLoginTextWidget);
widgetList.add(jVerifyCloseTextWidget);
/// 步骤 1:调用接口设置 UI
jverify.setCustomAuthorizationView(false, uiConfig,
landscapeConfig: uiConfig, widgets: widgetList);
/// 步骤 2:调用一键登录接口
/// 方式一:使用同步接口 (如果想使用异步接口,则忽略此步骤,看方式二)
/// 先,添加 loginAuthSyncApi 接口回调的监听
jverify.addLoginAuthCallBackListener((event) {
setState(() {
_hideLoading();
_hideLoading();
_result = "监听获取返回数据:[${event.code}] message = ${event.message}";
});
print(
"通过添加监听,获取到 loginAuthSyncApi 接口返回数据,code=${event.code},message = ${event.message},operator = ${event.operator}");
});
/// 再,执行同步的一键登录接口
jverify.loginAuthSyncApi(autoDismiss: true);
} else {
setState(() {
_hideLoading();
_result = "[2016],msg = 当前网络环境不支持认证";
});
/*
/// 方式二:使用异步接口 (如果想使用异步接口,则忽略此步骤,看方式二)
/// 先,执行异步的一键登录接口
jverify.loginAuth(true).then((map) {
/// 再,在回调里获取 loginAuth 接口异步返回数据(如果是通过添加 JVLoginAuthCallBackListener 监听来获取返回数据,则忽略此步骤)
int code = map[f_code_key];
String content = map[f_msg_key];
String operator = map[f_opr_key];
setState(() {
_hideLoading();
_result = "接口异步返回数据:[$code] message = $content";
});
print("通过接口异步返回,获取到 loginAuth 接口返回数据,code=$code,message = $content,operator = $operator");
});
*/
}
});
}