From 75d4e20d99e01aacba8c8c0e0199799db6907d69 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Tue, 11 Jul 2023 17:14:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=B4=E6=92=AD=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 5 + lib/http/live.dart | 25 +++++ lib/models/live/item.dart | 77 +++++++++++++ lib/pages/home/view.dart | 13 +-- lib/pages/live/controller.dart | 56 ++++++++++ lib/pages/live/index.dart | 4 + lib/pages/live/view.dart | 149 ++++++++++++++++++++++++++ lib/pages/live/widgets/live_item.dart | 137 +++++++++++++++++++++++ 8 files changed, 455 insertions(+), 11 deletions(-) create mode 100644 lib/http/live.dart create mode 100644 lib/models/live/item.dart create mode 100644 lib/pages/live/controller.dart create mode 100644 lib/pages/live/index.dart create mode 100644 lib/pages/live/view.dart create mode 100644 lib/pages/live/widgets/live_item.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index c6cfe397..57e776bf 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -185,4 +185,9 @@ class Api { // vmid 用户id pn 页码 ps 每页个数,最大50 order: desc // order_type 排序规则 最近访问传空,最常访问传 attention static const String fans = 'https://api.bilibili.com/x/relation/fans'; + + // 直播 + // ?page=1&page_size=30&platform=web + static const String liveList = + 'https://api.live.bilibili.com/xlive/web-interface/v1/second/getUserRecommend'; } diff --git a/lib/http/live.dart b/lib/http/live.dart new file mode 100644 index 00000000..8b43fea6 --- /dev/null +++ b/lib/http/live.dart @@ -0,0 +1,25 @@ +import 'package:pilipala/http/api.dart'; +import 'package:pilipala/http/init.dart'; +import 'package:pilipala/models/live/item.dart'; + +class LiveHttp { + static Future liveList( + {int? vmid, int? pn, int? ps, String? orderType}) async { + var res = await Request().get(Api.liveList, + data: {'page': pn, 'page_size': 30, 'platform': 'web'}); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data']['list'] + .map((e) => LiveItemModel.fromJson(e)) + .toList() + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } +} diff --git a/lib/models/live/item.dart b/lib/models/live/item.dart new file mode 100644 index 00000000..4cf3aefc --- /dev/null +++ b/lib/models/live/item.dart @@ -0,0 +1,77 @@ +class LiveItemModel { + LiveItemModel({ + this.roomId, + this.uid, + this.title, + this.uname, + this.online, + this.userCover, + this.userCoverFlag, + this.systemCover, + this.cover, + this.pic, + this.link, + this.face, + this.parentId, + this.parentName, + this.areaId, + this.areaName, + this.sessionId, + this.groupId, + this.pkId, + this.verify, + this.headBox, + this.headBoxType, + this.watchedShow, + }); + + int? roomId; + int? uid; + String? title; + String? uname; + int? online; + String? userCover; + int? userCoverFlag; + String? systemCover; + String? cover; + String? pic; + String? link; + String? face; + int? parentId; + String? parentName; + int? areaId; + String? areaName; + String? sessionId; + int? groupId; + int? pkId; + Map? verify; + Map? headBox; + int? headBoxType; + Map? watchedShow; + + LiveItemModel.fromJson(Map json) { + roomId = json['room_id']; + uid = json['uid']; + title = json['title']; + uname = json['uname']; + online = json['online']; + userCover = json['user_cover']; + userCoverFlag = json['user_cover_flag']; + systemCover = json['system_cover']; + cover = json['cover']; + pic = json['cover']; + link = json['link']; + face = json['face']; + parentId = json['parent_id']; + parentName = json['parent_name']; + areaId = json['area_id']; + areaName = json['area_name']; + sessionId = json['session_id']; + groupId = json['group_id']; + pkId = json['pk_id']; + verify = json['verify']; + headBox = json['head_box']; + headBoxType = json['head_box_type']; + watchedShow = json['watched_show']; + } +} diff --git a/lib/pages/home/view.dart b/lib/pages/home/view.dart index 35217f73..d5aebe17 100644 --- a/lib/pages/home/view.dart +++ b/lib/pages/home/view.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/pages/hot/index.dart'; +import 'package:pilipala/pages/live/index.dart'; import 'package:pilipala/pages/rcmd/index.dart'; import './controller.dart'; @@ -21,16 +22,6 @@ class _HomePageState extends State @override bool get wantKeepAlive => true; - @override - void initState() { - super.initState(); - // _tabController = TabController( - // initialIndex: _homeController.initialIndex, - // length: _homeController.tabs.length, - // vsync: this, - // ); - } - @override Widget build(BuildContext context) { super.build(context); @@ -106,7 +97,7 @@ class _HomePageState extends State body: TabBarView( controller: _homeController.tabController, children: const [ - SizedBox(), + LivePage(), RcmdPage(), HotPage(), ], diff --git a/lib/pages/live/controller.dart b/lib/pages/live/controller.dart new file mode 100644 index 00000000..2779659a --- /dev/null +++ b/lib/pages/live/controller.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/live.dart'; +import 'package:pilipala/models/live/item.dart'; + +class LiveController extends GetxController { + final ScrollController scrollController = ScrollController(); + int count = 12; + int _currentPage = 1; + int crossAxisCount = 2; + RxList liveList = [LiveItemModel()].obs; + bool isLoadingMore = false; + bool flag = false; + OverlayEntry? popupDialog; + + // 获取推荐 + Future queryLiveList(type) async { + if (type == 'init') { + _currentPage = 1; + } + var res = await LiveHttp.liveList( + pn: _currentPage, + ); + if (res['status']) { + if (type == 'init') { + liveList.value = res['data']; + } else if (type == 'onLoad') { + liveList.addAll(res['data']); + } + _currentPage += 1; + } + isLoadingMore = false; + return res; + } + + // 下拉刷新 + Future onRefresh() async { + queryLiveList('init'); + } + + // 上拉加载 + Future onLoad() async { + queryLiveList('onLoad'); + } + + // 返回顶部并刷新 + void animateToTop() async { + if (scrollController.offset >= + MediaQuery.of(Get.context!).size.height * 5) { + scrollController.jumpTo(0); + } else { + await scrollController.animateTo(0, + duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + } + } +} diff --git a/lib/pages/live/index.dart b/lib/pages/live/index.dart new file mode 100644 index 00000000..a847f497 --- /dev/null +++ b/lib/pages/live/index.dart @@ -0,0 +1,4 @@ +library live; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/live/view.dart b/lib/pages/live/view.dart new file mode 100644 index 00000000..99904fe0 --- /dev/null +++ b/lib/pages/live/view.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/skeleton/video_card_v.dart'; +import 'package:pilipala/common/widgets/animated_dialog.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/overlay_pop.dart'; + +import 'controller.dart'; +import 'widgets/live_item.dart'; + +class LivePage extends StatefulWidget { + const LivePage({super.key}); + + @override + State createState() => _LivePageState(); +} + +class _LivePageState extends State { + final LiveController _liveController = Get.put(LiveController()); + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _liveController.scrollController.addListener( + () { + if (_liveController.scrollController.position.pixels >= + _liveController.scrollController.position.maxScrollExtent - 200) { + if (!_liveController.isLoadingMore) { + _liveController.isLoadingMore = true; + _liveController.onLoad(); + } + } + }, + ); + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () async { + return await _liveController.onRefresh(); + }, + child: CustomScrollView( + controller: _liveController.scrollController, + slivers: [ + SliverPadding( + // 单列布局 EdgeInsets.zero + padding: const EdgeInsets.fromLTRB( + StyleString.cardSpace, 0, StyleString.cardSpace, 8), + sliver: FutureBuilder( + future: _liveController.queryLiveList('init'), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + return Obx(() => + contentGrid(_liveController, _liveController.liveList)); + } else { + return HttpError( + errMsg: data['msg'], + fn: () => {}, + ); + } + } else { + // 缓存数据 + if (_liveController.liveList.length > 1) { + return contentGrid( + _liveController, _liveController.liveList); + } + // 骨架屏 + else { + return contentGrid(_liveController, []); + } + } + }, + ), + ), + const LoadingMore() + ], + ), + ); + } + + OverlayEntry _createPopupDialog(liveItem) { + return OverlayEntry( + builder: (context) => AnimatedDialog( + child: OverlayPop(videoItem: liveItem), + ), + ); + } + + Widget contentGrid(ctr, liveList) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // 行间距 + mainAxisSpacing: StyleString.cardSpace, + // 列间距 + crossAxisSpacing: StyleString.cardSpace, + // 列数 + crossAxisCount: ctr.crossAxisCount, + mainAxisExtent: + Get.size.width / ctr.crossAxisCount / StyleString.aspectRatio + 70, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return liveList!.isNotEmpty + ? LiveCardV( + liveItem: liveList[index], + longPress: () { + _liveController.popupDialog = + _createPopupDialog(liveList[index]); + Overlay.of(context).insert(_liveController.popupDialog!); + }, + longPressEnd: () { + _liveController.popupDialog?.remove(); + }, + ) + : const VideoCardVSkeleton(); + }, + childCount: liveList!.isNotEmpty ? liveList!.length : 10, + ), + ); + } +} + +class LoadingMore extends StatelessWidget { + const LoadingMore({super.key}); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Container( + height: MediaQuery.of(context).padding.bottom + 80, + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + child: Center( + child: Text( + '加载中...', + style: TextStyle( + color: Theme.of(context).colorScheme.outline, fontSize: 13), + ), + ), + ), + ); + } +} diff --git a/lib/pages/live/widgets/live_item.dart b/lib/pages/live/widgets/live_item.dart new file mode 100644 index 00000000..f13ee0eb --- /dev/null +++ b/lib/pages/live/widgets/live_item.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/models/live/item.dart'; +import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; + +// 视频卡片 - 垂直布局 +class LiveCardV extends StatelessWidget { + LiveItemModel liveItem; + Function()? longPress; + Function()? longPressEnd; + + LiveCardV({ + Key? key, + required this.liveItem, + this.longPress, + this.longPressEnd, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + String heroTag = Utils.makeHeroTag(liveItem.roomId); + return Card( + elevation: 0, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: StyleString.mdRadius, + ), + margin: EdgeInsets.zero, + child: GestureDetector( + onLongPress: () { + if (longPress != null) { + longPress!(); + } + }, + onLongPressEnd: (details) { + if (longPressEnd != null) { + longPressEnd!(); + } + }, + child: InkWell( + onTap: () async { + await Future.delayed(const Duration(milliseconds: 200)); + // Get.toNamed('/video?bvid=${liveItem.bvid}&cid=${liveItem.cid}', + // arguments: {'videoItem': liveItem, 'heroTag': heroTag}); + }, + child: Column( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(StyleString.imgRadius), + child: AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder(builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Stack( + children: [ + Hero( + tag: heroTag, + child: NetworkImgLayer( + src: '${liveItem.cover!}@.webp', + width: maxWidth, + height: maxHeight, + ), + ), + ], + ); + }), + ), + ), + LiveContent(liveItem: liveItem) + ], + ), + ), + ), + ); + } +} + +class LiveContent extends StatelessWidget { + final liveItem; + const LiveContent({Key? key, required this.liveItem}) : super(key: key); + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + // 多列 + padding: const EdgeInsets.fromLTRB(4, 8, 6, 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + liveItem.title, + textAlign: TextAlign.start, + style: const TextStyle( + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Row( + children: [ + UpTag(), + Expanded( + child: Text( + liveItem.uname, + textAlign: TextAlign.start, + style: const TextStyle( + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + ], + ), + Row( + children: [ + Text( + '${'[' + liveItem.areaName}]', + style: const TextStyle(fontSize: 11), + ), + const Text(' • '), + Text( + liveItem.watchedShow['text_large'], + style: const TextStyle(fontSize: 11), + ), + ], + ), + ], + ), + ), + ); + } +}