From e426236741abd552e3e69c8325f6ca855871ff8f Mon Sep 17 00:00:00 2001 From: guozhigq Date: Thu, 11 May 2023 13:45:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E8=97=8F=E5=A4=B9=E8=AF=A6?= =?UTF-8?q?=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 10 + lib/http/user.dart | 24 ++ lib/models/user/fav_detail.dart | 100 +++++++++ lib/pages/favDetail/controller.dart | 29 +++ lib/pages/favDetail/index.dart | 4 + lib/pages/favDetail/view.dart | 206 ++++++++++++++++++ .../favDetail/widget/fav_video_card.dart | 143 ++++++++++++ lib/pages/media/view.dart | 98 +++++---- .../video/detail/introduction/controller.dart | 4 +- lib/router/app_pages.dart | 3 + 10 files changed, 575 insertions(+), 46 deletions(-) create mode 100644 lib/models/user/fav_detail.dart create mode 100644 lib/pages/favDetail/controller.dart create mode 100644 lib/pages/favDetail/index.dart create mode 100644 lib/pages/favDetail/view.dart create mode 100644 lib/pages/favDetail/widget/fav_video_card.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index 80a3882d..b47272d2 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -30,4 +30,14 @@ class Api { // 收藏夹 // https://api.bilibili.com/x/v3/fav/folder/created/list?pn=1&ps=10&up_mid=17340771 static const String userFavFolder = '/x/v3/fav/folder/created/list'; + + /// 收藏夹 详情 + /// media_id int 收藏夹id + /// pn int 当前页 + /// ps int pageSize + /// keyword String 搜索词 + /// order String 排序方式 view 最多播放 mtime 最近收藏 pubtime 最近投稿 + /// tid int 分区id + // https://api.bilibili.com/x/v3/fav/resource/list?media_id=76614671&pn=1&ps=20&keyword=&order=mtime&type=0&tid=0 + static const String userFavFolderDetail = '/x/v3/fav/resource/list'; } diff --git a/lib/http/user.dart b/lib/http/user.dart index bf23d3ee..6d4be974 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -1,5 +1,6 @@ import 'package:pilipala/http/api.dart'; import 'package:pilipala/http/init.dart'; +import 'package:pilipala/models/user/fav_detail.dart'; import 'package:pilipala/models/user/fav_folder.dart'; import 'package:pilipala/models/user/info.dart'; import 'package:pilipala/models/user/stat.dart'; @@ -52,4 +53,27 @@ class UserHttp { return {'status': false, 'data': [], 'msg': res.data['message']}; } } + + static Future userFavFolderDetail( + {required int mediaId, + required int pn, + required int ps, + String keyword = '', + String order = 'mtime'}) async { + var res = await Request().get(Api.userFavFolderDetail, data: { + 'media_id': mediaId, + 'pn': pn, + 'ps': ps, + 'keyword': keyword, + 'order': order, + 'type': 0, + 'tid': 0 + }); + if (res.data['code'] == 0) { + FavDetailData data = FavDetailData.fromJson(res.data['data']); + return {'status': true, 'data': data}; + } else { + return {'status': false, 'data': [], 'msg': res.data['message']}; + } + } } diff --git a/lib/models/user/fav_detail.dart b/lib/models/user/fav_detail.dart new file mode 100644 index 00000000..3085dc9d --- /dev/null +++ b/lib/models/user/fav_detail.dart @@ -0,0 +1,100 @@ +import 'package:pilipala/models/model_owner.dart'; + +class FavDetailData { + FavDetailData({ + this.info, + this.medias, + this.hasMore, + }); + + Map? info; + List? medias; + bool? hasMore; + + FavDetailData.fromJson(Map json) { + info = json['info']; + medias = json['medias'] != null + ? json['medias'] + .map((e) => FavDetailItemData.fromJson(e)) + .toList() + : [FavDetailItemData()]; + hasMore = json['has_more']; + } +} + +class FavDetailItemData { + FavDetailItemData({ + this.id, + this.type, + this.title, + this.pic, + this.intro, + this.page, + this.duration, + this.owner, + this.attr, + this.cntInfo, + this.link, + this.ctime, + this.pubdate, + this.favTime, + this.bvId, + this.bvid, + // this.season, + // this.ogv, + this.stat, + }); + + int? id; + int? type; + String? title; + String? pic; + String? intro; + int? page; + int? duration; + Owner? owner; + int? attr; + Map? cntInfo; + String? link; + int? ctime; + int? pubdate; + int? favTime; + String? bvId; + String? bvid; + Stat? stat; + + FavDetailItemData.fromJson(Map json) { + id = json['id']; + type = json['type']; + title = json['title']; + pic = json['cover']; + intro = json['intro']; + page = json['page']; + duration = json['duration']; + owner = Owner.fromJson(json['upper']); + attr = json['attr']; + cntInfo = json['cnt_info']; + link = json['link']; + ctime = json['ctime']; + pubdate = json['pubtime']; + favTime = json['fav_time']; + bvId = json['bv_id']; + bvid = json['bvid']; + stat = Stat.fromJson(json['cnt_info']); + } +} + +class Stat { + Stat({ + this.view, + this.danmaku, + }); + + int? view; + int? danmaku; + + Stat.fromJson(Map json) { + view = json['play']; + danmaku = json['danmaku']; + } +} diff --git a/lib/pages/favDetail/controller.dart b/lib/pages/favDetail/controller.dart new file mode 100644 index 00000000..21303115 --- /dev/null +++ b/lib/pages/favDetail/controller.dart @@ -0,0 +1,29 @@ +import 'package:get/get.dart'; +import 'package:pilipala/http/user.dart'; +import 'package:pilipala/models/user/fav_detail.dart'; +import 'package:pilipala/models/user/fav_folder.dart'; + +class FavDetailController extends GetxController { + FavFolderItemData? item; + Rx favDetailData = FavDetailData().obs; + int? mediaId; + + @override + void onInit() { + item = Get.arguments; + if (Get.parameters.keys.isNotEmpty) { + mediaId = int.parse(Get.parameters['mediaId']!); + } + super.onInit(); + } + + Future queryUserFavFolderDetail() async { + var res = await await UserHttp.userFavFolderDetail( + pn: 1, + ps: 15, + mediaId: mediaId!, + ); + favDetailData.value = res['data']; + return res; + } +} diff --git a/lib/pages/favDetail/index.dart b/lib/pages/favDetail/index.dart new file mode 100644 index 00000000..dfeafac8 --- /dev/null +++ b/lib/pages/favDetail/index.dart @@ -0,0 +1,4 @@ +library favdetail; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/favDetail/view.dart b/lib/pages/favDetail/view.dart new file mode 100644 index 00000000..1fb48886 --- /dev/null +++ b/lib/pages/favDetail/view.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/pages/favDetail/index.dart'; + +import 'widget/fav_video_card.dart'; + +class FavDetailPage extends StatefulWidget { + const FavDetailPage({super.key}); + + @override + State createState() => _FavDetailPageState(); +} + +class _FavDetailPageState extends State { + late final ScrollController _controller = ScrollController(); + final FavDetailController _favDetailController = + Get.put(FavDetailController()); + late StreamController titleStreamC; // a + + @override + void initState() { + super.initState(); + titleStreamC = StreamController(); + _controller.addListener( + () { + if (_controller.offset > 160) { + titleStreamC.add(true); + } else if (_controller.offset <= 160) { + titleStreamC.add(false); + } + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + controller: _controller, + slivers: [ + SliverAppBar( + expandedHeight: 260 - MediaQuery.of(context).padding.top, + pinned: true, + title: StreamBuilder( + stream: titleStreamC.stream, + initialData: false, + builder: (context, AsyncSnapshot snapshot) { + return AnimatedOpacity( + opacity: snapshot.data ? 1 : 0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 500), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _favDetailController.item!.title!, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '共${_favDetailController.item!.mediaCount!}条视频', + style: Theme.of(context).textTheme.labelMedium, + ) + ], + ) + ], + ), + ); + }, + ), + // actions: [ + // IconButton( + // onPressed: () {}, + // icon: const Icon(Icons.more_vert), + // ), + // const SizedBox(width: 4) + // ], + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withOpacity(0.2), + ), + ), + ), + padding: EdgeInsets.only( + top: kTextTabBarHeight + + MediaQuery.of(context).padding.top + + 30, + left: 20, + right: 20), + child: SizedBox( + height: 200, + child: Row( + // mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 180, + height: 110, + child: NetworkImgLayer( + width: 180, + height: 110, + src: _favDetailController.item!.cover, + ), + ), + const SizedBox(width: 14), + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + _favDetailController.item!.title!, + style: TextStyle( + fontSize: Theme.of(context) + .textTheme + .titleMedium! + .fontSize, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + _favDetailController.item!.upper!.name!, + style: TextStyle( + fontSize: Theme.of(context) + .textTheme + .labelSmall! + .fontSize, + color: Theme.of(context).colorScheme.outline), + ) + ], + ) + ], + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 15, bottom: 8, left: 14), + child: Text( + '共${_favDetailController.item!.mediaCount}条视频', + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + letterSpacing: 1), + ), + ), + ), + FutureBuilder( + future: _favDetailController.queryUserFavFolderDetail(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data; + if (data['status']) { + return Obx( + () => SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return FavVideoCardH( + videoItem: _favDetailController + .favDetailData.value.medias![index], + ); + }, + childCount: _favDetailController + .favDetailData.value.medias!.length), + ), + ); + } else { + return HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ); + } + } else { + return const SliverToBoxAdapter( + child: Center( + child: Text('加载中'), + ), + ); + } + }, + ), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).padding.bottom + 20, + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/favDetail/widget/fav_video_card.dart b/lib/pages/favDetail/widget/fav_video_card.dart new file mode 100644 index 00000000..1cfc3c91 --- /dev/null +++ b/lib/pages/favDetail/widget/fav_video_card.dart @@ -0,0 +1,143 @@ +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/widgets/stat/up.dart'; +import 'package:pilipala/common/widgets/stat/view.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; + +// 收藏视频卡片 - 水平布局 +class FavVideoCardH extends StatelessWidget { + var videoItem; + + FavVideoCardH({Key? key, required this.videoItem}) : super(key: key); + + @override + Widget build(BuildContext context) { + int id = videoItem.id; + String heroTag = Utils.makeHeroTag(id); + return InkWell( + onTap: () async { + await Future.delayed(const Duration(milliseconds: 200)); + Get.toNamed('/video?aid=$id', + arguments: {'videoItem': videoItem, 'heroTag': heroTag}); + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 5, 12, 5), + child: LayoutBuilder( + builder: (context, boxConstraints) { + double width = + (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; + return SizedBox( + height: width / StyleString.aspectRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + double PR = MediaQuery.of(context).devicePixelRatio; + return Stack( + children: [ + Hero( + tag: heroTag, + child: NetworkImgLayer( + // src: videoItem['pic'] + + // '@${(maxWidth * 2).toInt()}w', + src: videoItem.pic + '@.webp', + width: maxWidth, + height: maxHeight, + ), + ), + // Image.network( videoItem['pic'], width: double.infinity, height: double.infinity,), + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 1, horizontal: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.black54.withOpacity(0.4)), + child: Text( + Utils.timeFormat(videoItem.duration!), + style: const TextStyle( + fontSize: 11, color: Colors.white), + ), + ), + ) + ], + ); + }, + ), + ), + VideoContent(videoItem: videoItem) + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class VideoContent extends StatelessWidget { + final videoItem; + const VideoContent({super.key, required this.videoItem}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + videoItem.title, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: Theme.of(context).textTheme.titleSmall!.fontSize, + fontWeight: FontWeight.w500), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + const SizedBox(height: 4), + Text( + videoItem.owner.name, + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + ), + Row( + children: [ + StatView( + theme: 'gray', + view: videoItem.cntInfo['play'], + ), + const SizedBox(width: 8), + Text( + Utils.dateFormat(videoItem.pubdate!), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline), + ) + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/media/view.dart b/lib/pages/media/view.dart index dd2b0446..c4474a28 100644 --- a/lib/pages/media/view.dart +++ b/lib/pages/media/view.dart @@ -40,6 +40,7 @@ class _MediaPageState extends State '媒体库', style: TextStyle( fontSize: Theme.of(context).textTheme.titleLarge!.fontSize, + fontWeight: FontWeight.bold, ), ), ), @@ -47,6 +48,7 @@ class _MediaPageState extends State for (var i in _mediaController.list) ...[ ListTile( onTap: () => i['onTap'](), + dense: true, leading: Padding( padding: const EdgeInsets.only(left: 15), child: Icon( @@ -84,9 +86,9 @@ class _MediaPageState extends State TextSpan( text: '收藏夹 ', style: TextStyle( - fontSize: - Theme.of(context).textTheme.titleMedium!.fontSize, - ), + fontSize: + Theme.of(context).textTheme.titleMedium!.fontSize, + fontWeight: FontWeight.bold), ), if (_mediaController.favFolderData.value.count != null) TextSpan( @@ -165,50 +167,56 @@ class FavFolderItem extends StatelessWidget { FavFolderItemData? item; @override Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 12), - Container( - width: 110 * 16 / 9, - height: 110, - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: Theme.of(context).colorScheme.onInverseSurface, - boxShadow: [ - BoxShadow( - color: Theme.of(context).colorScheme.onInverseSurface, - offset: const Offset(4, -12), // 阴影与容器的距离 - blurRadius: 0.0, // 高斯的标准偏差与盒子的形状卷积。 - spreadRadius: 0.0, // 在应用模糊之前,框应该膨胀的量。 - ), - ], + return GestureDetector( + onTap: () => Get.toNamed('/favDetail', arguments: item, parameters: { + 'mediaId': item!.id.toString(), + }), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Container( + width: 180, + height: 110, + margin: const EdgeInsets.only(bottom: 8), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.onInverseSurface, + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.onInverseSurface, + offset: const Offset(4, -12), // 阴影与容器的距离 + blurRadius: 0.0, // 高斯的标准偏差与盒子的形状卷积。 + spreadRadius: 0.0, // 在应用模糊之前,框应该膨胀的量。 + ), + ], + ), + child: LayoutBuilder( + builder: (context, BoxConstraints box) { + return NetworkImgLayer( + src: item!.cover, + width: box.maxWidth, + height: box.maxHeight, + ); + }, + ), ), - child: LayoutBuilder( - builder: (context, BoxConstraints box) { - return NetworkImgLayer( - src: item!.cover, - width: box.maxWidth, - height: box.maxHeight, - ); - }, + Text( + ' ${item!.title}', + overflow: TextOverflow.fade, + maxLines: 1, ), - ), - Text( - ' ${item!.title}', - overflow: TextOverflow.fade, - maxLines: 1, - ), - Text( - ' 共${item!.mediaCount}条视频', - style: Theme.of(context) - .textTheme - .labelSmall! - .copyWith(color: Theme.of(context).colorScheme.outline), - ) - ], + Text( + ' 共${item!.mediaCount}条视频', + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: Theme.of(context).colorScheme.outline), + ) + ], + ), ); } } diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 191a6d43..1ddaf530 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -35,7 +35,9 @@ class VideoIntroController extends GetxController { var args = Get.arguments['videoItem']; videoItem!['pic'] = args.pic; videoItem!['title'] = args.title; - videoItem!['stat'] = args.stat; + if (args.stat != null) { + videoItem!['stat'] = args.stat; + } videoItem!['pubdate'] = args.pubdate; videoItem!['owner'] = args.owner; } diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 9b7e385b..1ac52a93 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import 'package:pilipala/pages/fav/index.dart'; +import 'package:pilipala/pages/favDetail/index.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/hot/index.dart'; import 'package:pilipala/pages/preview/index.dart'; @@ -26,5 +27,7 @@ class Routes { GetPage(name: '/media', page: () => const MediaPage()), // GetPage(name: '/fav', page: () => const FavPage()), + // + GetPage(name: '/favDetail', page: () => const FavDetailPage()), ]; }