【Flutter】仿照哔哩哔哩做的APP

CSDN我打算过段时间再更新了,现在都在b站不断更新,包括进度,更新内容的演示视频

点击进入b站更新
bilibili_getx gitee地址
在这里插入图片描述

项目仓库转到gitee上去了,不用github了

0.1 速览界面

在这里插入图片描述

视频弹幕(计时器+动画+position.fill定位)

在这里插入图片描述

我的界面&扫描

在这里插入图片描述

在这里插入图片描述

视频界面

在这里插入图片描述

Jverify一键登录界面

在这里插入图片描述
在这里插入图片描述

统计图表fl_chart

在这里插入图片描述

0.2 更新日志

更新日期更新时间版本
5月18日1、规划项目框架;2、内容写至2.50.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");
        });
        */
      }
    });
  }
  • 10
    点赞
  • 56
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sheng_er_sheng

打赏是什么?好吃么

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值