From c8a8544857e7e92a846e0a29304b44c9edc804fe Mon Sep 17 00:00:00 2001 From: guozhigq Date: Sun, 20 Oct 2024 22:36:55 +0800 Subject: [PATCH] opt: pagesList style --- lib/common/pages_bottom_sheet.dart | 628 ++++++++++++++---- lib/models/bangumi/info.dart | 7 +- lib/models/video_detail_res.dart | 9 + .../bangumi/introduction/controller.dart | 3 +- lib/pages/bangumi/widgets/bangumi_panel.dart | 1 - .../video/detail/introduction/controller.dart | 6 +- .../introduction/widgets/page_panel.dart | 1 - .../introduction/widgets/season_panel.dart | 2 +- 8 files changed, 532 insertions(+), 125 deletions(-) diff --git a/lib/common/pages_bottom_sheet.dart b/lib/common/pages_bottom_sheet.dart index 49e9b4d8..d7eea2ca 100644 --- a/lib/common/pages_bottom_sheet.dart +++ b/lib/common/pages_bottom_sheet.dart @@ -1,35 +1,249 @@ import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/constants.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/video_detail_res.dart'; +import 'package:pilipala/utils/utils.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../models/common/video_episode_type.dart'; +import 'widgets/badge.dart'; +import 'widgets/stat/danmu.dart'; +import 'widgets/stat/view.dart'; class EpisodeBottomSheet { final List episodes; final int currentCid; final dynamic dataType; - final BuildContext context; final Function changeFucCall; final int? cid; final double? sheetHeight; bool isFullScreen = false; + final UgcSeason? ugcSeason; EpisodeBottomSheet({ required this.episodes, required this.currentCid, required this.dataType, - required this.context, required this.changeFucCall, this.cid, this.sheetHeight, this.isFullScreen = false, + this.ugcSeason, }); - Widget buildEpisodeListItem( - dynamic episode, - int index, - bool isCurrentIndex, - ) { + Widget buildShowContent() { + return PagesBottomSheet( + episodes: episodes, + currentCid: currentCid, + dataType: dataType, + changeFucCall: changeFucCall, + cid: cid, + sheetHeight: sheetHeight, + isFullScreen: isFullScreen, + ugcSeason: ugcSeason, + ); + } + + PersistentBottomSheetController show(BuildContext context) { + final PersistentBottomSheetController btmSheetCtr = showBottomSheet( + context: context, + builder: (BuildContext context) { + return buildShowContent(); + }, + ); + return btmSheetCtr; + } +} + +class PagesBottomSheet extends StatefulWidget { + const PagesBottomSheet({ + super.key, + required this.episodes, + required this.currentCid, + required this.dataType, + required this.changeFucCall, + this.cid, + this.sheetHeight, + this.isFullScreen = false, + this.ugcSeason, + }); + + final List episodes; + final int currentCid; + final dynamic dataType; + final Function changeFucCall; + final int? cid; + final double? sheetHeight; + final bool isFullScreen; + final UgcSeason? ugcSeason; + + @override + State createState() => _PagesBottomSheetState(); +} + +class _PagesBottomSheetState extends State { + final ItemScrollController _itemScrollController = ItemScrollController(); + final ScrollController _scrollController = ScrollController(); + late int currentIndex; + + @override + void initState() { + super.initState(); + currentIndex = + widget.episodes.indexWhere((dynamic e) => e.cid == widget.currentCid); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.dataType == VideoEpidoesType.videoEpisode) { + _itemScrollController.jumpTo(index: currentIndex); + } else { + double itemHeight = (widget.isFullScreen + ? 400 + : Get.size.width - 3 * StyleString.safeSpace) / + 5.2; + double offset = ((currentIndex - 1) / 2).ceil() * itemHeight; + _scrollController.jumpTo(offset); + } + }); + } + + String prefix() { + switch (widget.dataType) { + case VideoEpidoesType.videoEpisode: + return '选集'; + case VideoEpidoesType.videoPart: + return '分集'; + case VideoEpidoesType.bangumiEpisode: + return '选集'; + } + return '选集'; + } + + @override + Widget build(BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SizedBox( + height: widget.sheetHeight, + child: Column( + children: [ + TitleBar( + title: '${prefix()}(${widget.episodes.length})', + isFullScreen: widget.isFullScreen, + ), + if (widget.ugcSeason != null) ...[ + UgcSeasonBuild(ugcSeason: widget.ugcSeason!), + ], + Expanded( + child: Material( + child: widget.dataType == VideoEpidoesType.videoEpisode + ? ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + itemCount: widget.episodes.length + 1, + itemBuilder: (BuildContext context, int index) { + bool isLastItem = index == widget.episodes.length; + bool isCurrentIndex = currentIndex == index; + return isLastItem + ? SizedBox( + height: + MediaQuery.of(context).padding.bottom + + 20, + ) + : EpisodeListItem( + episode: widget.episodes[index], + index: index, + isCurrentIndex: isCurrentIndex, + dataType: widget.dataType, + changeFucCall: widget.changeFucCall, + isFullScreen: widget.isFullScreen, + ); + }, + ) + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0), // 设置左右间距为12 + child: GridView.count( + controller: _scrollController, + crossAxisCount: 2, + crossAxisSpacing: StyleString.safeSpace, + childAspectRatio: 2.6, + children: List.generate( + widget.episodes.length, + (index) { + bool isCurrentIndex = currentIndex == index; + return EpisodeGridItem( + episode: widget.episodes[index], + index: index, + isCurrentIndex: isCurrentIndex, + dataType: widget.dataType, + changeFucCall: widget.changeFucCall, + isFullScreen: widget.isFullScreen, + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + }); + } +} + +class TitleBar extends StatelessWidget { + final String title; + final bool isFullScreen; + + const TitleBar({ + Key? key, + required this.title, + required this.isFullScreen, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AppBar( + toolbarHeight: 45, + automaticallyImplyLeading: false, + centerTitle: false, + title: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + actions: !isFullScreen + ? [ + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 14), + ] + : null, + ); + } +} + +class EpisodeListItem extends StatelessWidget { + final dynamic episode; + final int index; + final bool isCurrentIndex; + final dynamic dataType; + final Function changeFucCall; + final bool isFullScreen; + + const EpisodeListItem({ + Key? key, + required this.episode, + required this.index, + required this.isCurrentIndex, + required this.dataType, + required this.changeFucCall, + required this.isFullScreen, + }) : super(key: key); + + @override + Widget build(BuildContext context) { Color primary = Theme.of(context).colorScheme.primary; Color onSurface = Theme.of(context).colorScheme.onSurface; @@ -45,128 +259,308 @@ class EpisodeBottomSheet { title = '第${episode.title}话 ${episode.longTitle!}'; break; } + return isFullScreen || episode?.cover == null || episode?.cover == '' - ? ListTile( - onTap: () { - SmartDialog.showToast('切换至「$title」'); - changeFucCall.call(episode, index); - }, - dense: false, - leading: isCurrentIndex - ? Image.asset( - 'assets/images/live.gif', - color: primary, - height: 12, + ? _buildListTile(context, title, primary, onSurface) + : _buildInkWell(context, title, primary, onSurface); + } + + Widget _buildListTile( + BuildContext context, String title, Color primary, Color onSurface) { + return ListTile( + onTap: () { + if (isCurrentIndex) { + return; + } + SmartDialog.showToast('切换至「$title」'); + changeFucCall.call(episode, index); + }, + dense: false, + leading: isCurrentIndex + ? Image.asset( + 'assets/images/live.gif', + color: primary, + height: 12, + ) + : null, + title: Text( + title, + style: TextStyle( + fontSize: 14, + color: isCurrentIndex ? primary : onSurface, + ), + ), + ); + } + + Widget _buildInkWell( + BuildContext context, String title, Color primary, Color onSurface) { + return InkWell( + onTap: () { + if (isCurrentIndex) { + return; + } + SmartDialog.showToast('切换至「$title」'); + changeFucCall.call(episode, index); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB( + StyleString.safeSpace, 6, StyleString.safeSpace, 6), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints boxConstraints) { + const double width = 160; + return Container( + constraints: const BoxConstraints(minHeight: 88), + height: width / StyleString.aspectRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (BuildContext context, + BoxConstraints boxConstraints) { + final double maxWidth = boxConstraints.maxWidth; + final double maxHeight = boxConstraints.maxHeight; + return Stack( + children: [ + NetworkImgLayer( + src: episode?.cover ?? '', + width: maxWidth, + height: maxHeight, + ), + if (episode.duration != 0) + PBadge( + text: Utils.timeFormat(episode.duration!), + right: 6.0, + bottom: 6.0, + type: 'gray', + ), + ], + ); + }, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + episode.title as String, + textAlign: TextAlign.start, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w500, + color: isCurrentIndex ? primary : onSurface, + ), + ), + const Spacer(), + if (dataType != VideoEpidoesType.videoPart) ...[ + if (episode?.pubdate != null || + episode.pubTime != null) + Text( + Utils.dateFormat( + episode?.pubdate ?? episode.pubTime), + style: TextStyle( + fontSize: 11, + color: + Theme.of(context).colorScheme.outline), + ), + const SizedBox(height: 2), + if (episode.stat != null) + Row( + children: [ + StatView(view: episode.stat.view), + const SizedBox(width: 8), + StatDanMu(danmu: episode.stat.danmaku), + const Spacer(), + ], + ), + const SizedBox(height: 4), + ] + ], + ), + ), ) - : null, - title: Text(title, - style: TextStyle( - fontSize: 14, - color: isCurrentIndex ? primary : onSurface, - ))) - : InkWell( + ], + ), + ); + }, + ), + ), + ); + } +} + +class EpisodeGridItem extends StatelessWidget { + final dynamic episode; + final int index; + final bool isCurrentIndex; + final dynamic dataType; + final Function changeFucCall; + final bool isFullScreen; + + const EpisodeGridItem({ + Key? key, + required this.episode, + required this.index, + required this.isCurrentIndex, + required this.dataType, + required this.changeFucCall, + required this.isFullScreen, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + ColorScheme colorScheme = Theme.of(context).colorScheme; + TextStyle textStyle = TextStyle( + color: isCurrentIndex ? colorScheme.primary : colorScheme.onSurface, + fontSize: 14, + ); + return Stack( + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.only(top: StyleString.safeSpace), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: isCurrentIndex + ? colorScheme.primaryContainer.withOpacity(0.6) + : colorScheme.secondaryContainer.withOpacity(0.4), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCurrentIndex + ? colorScheme.primary.withOpacity(0.8) + : Colors.transparent, + width: 1, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), onTap: () { - SmartDialog.showToast('切换至「$title」'); + if (isCurrentIndex) { + return; + } + SmartDialog.showToast('切换至「${episode.title}」'); changeFucCall.call(episode, index); }, child: Padding( - padding: - const EdgeInsets.only(left: 14, right: 14, top: 8, bottom: 8), - child: Row( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, children: [ - NetworkImgLayer( - width: 130, height: 75, src: episode?.cover ?? ''), - const SizedBox(width: 10), - Expanded( - child: Text( - title, - maxLines: 2, - style: TextStyle( - fontSize: 14, - color: isCurrentIndex ? primary : onSurface, - ), - ), + Text( + dataType == VideoEpidoesType.bangumiEpisode + ? '第${index + 1}话' + : '第${index + 1}p', + style: textStyle), + const SizedBox(height: 1), + Text( + episode.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, ), ], ), ), - ); - } - - Widget buildTitle() { - return AppBar( - toolbarHeight: 45, - automaticallyImplyLeading: false, - centerTitle: false, - title: Text( - '合集(${episodes.length})', - style: Theme.of(context).textTheme.titleMedium, - ), - actions: !isFullScreen - ? [ - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - ), - const SizedBox(width: 14), - ] - : null, - ); - } - - Widget buildShowContent(BuildContext context) { - final ItemScrollController itemScrollController = ItemScrollController(); - int currentIndex = episodes.indexWhere((dynamic e) => e.cid == currentCid); - return StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - WidgetsBinding.instance.addPostFrameCallback((_) { - itemScrollController.jumpTo(index: currentIndex); - }); - return Container( - height: sheetHeight, - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - buildTitle(), - Expanded( - child: Material( - child: PageStorage( - bucket: PageStorageBucket(), - child: ScrollablePositionedList.builder( - itemScrollController: itemScrollController, - itemCount: episodes.length + 1, - itemBuilder: (BuildContext context, int index) { - bool isLastItem = index == episodes.length; - bool isCurrentIndex = currentIndex == index; - return isLastItem - ? SizedBox( - height: - MediaQuery.of(context).padding.bottom + 20, - ) - : buildEpisodeListItem( - episodes[index], - index, - isCurrentIndex, - ); - }, - ), - ), - ), - ), - ], + ), ), - ); - }); - } - - /// The [BuildContext] of the widget that calls the bottom sheet. - PersistentBottomSheetController show(BuildContext context) { - final PersistentBottomSheetController btmSheetCtr = showBottomSheet( - context: context, - builder: (BuildContext context) { - return buildShowContent(context); - }, + if (dataType == VideoEpidoesType.bangumiEpisode && + episode.badge != '' && + episode.badge != null) + Positioned( + right: 8, + top: 18, + child: Text( + episode.badge, + style: const TextStyle(fontSize: 11, color: Color(0xFFFF6699)), + ), + ) + ], + ); + } +} + +class UgcSeasonBuild extends StatelessWidget { + final UgcSeason ugcSeason; + + const UgcSeasonBuild({ + Key? key, + required this.ugcSeason, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + color: Theme.of(context).colorScheme.surface, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider( + height: 1, + thickness: 1, + color: Theme.of(context).dividerColor.withOpacity(0.1), + ), + const SizedBox(height: 10), + Text( + '合集:${ugcSeason.title}', + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + if (ugcSeason.intro != null && ugcSeason.intro != '') ...[ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text(ugcSeason.intro ?? '', + style: TextStyle( + color: Theme.of(context).colorScheme.outline)), + ), + // SizedBox( + // height: 32, + // child: FilledButton.tonal( + // onPressed: () {}, + // style: ButtonStyle( + // padding: MaterialStateProperty.all(EdgeInsets.zero), + // ), + // child: const Text('订阅'), + // ), + // ), + // const SizedBox(width: 6), + ], + ), + ], + const SizedBox(height: 4), + Text.rich( + TextSpan( + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + children: [ + TextSpan(text: '${Utils.numFormat(ugcSeason.stat!.view)}播放'), + const TextSpan(text: ' · '), + TextSpan(text: '${Utils.numFormat(ugcSeason.stat!.danmaku)}弹幕'), + ], + ), + ), + const SizedBox(height: 14), + Divider( + height: 1, + thickness: 1, + color: Theme.of(context).dividerColor.withOpacity(0.1), + ), + ], + ), ); - return btmSheetCtr; } } diff --git a/lib/models/bangumi/info.dart b/lib/models/bangumi/info.dart index b71d327e..2d76498b 100644 --- a/lib/models/bangumi/info.dart +++ b/lib/models/bangumi/info.dart @@ -144,6 +144,7 @@ class EpisodeItem { this.link, this.longTitle, this.pubTime, + this.pubdate, this.pv, this.releaseDate, this.rights, @@ -155,6 +156,7 @@ class EpisodeItem { this.subtitle, this.title, this.vid, + this.stat, }); int? aid; @@ -173,6 +175,7 @@ class EpisodeItem { String? link; String? longTitle; int? pubTime; + int? pubdate; int? pv; String? releaseDate; Map? rights; @@ -184,6 +187,7 @@ class EpisodeItem { String? subtitle; String? title; String? vid; + String? stat; EpisodeItem.fromJson(Map json) { aid = json['aid']; @@ -202,6 +206,7 @@ class EpisodeItem { link = json['link']; longTitle = json['long_title']; pubTime = json['pub_time']; + pubdate = json['pub_time']; pv = json['pv']; releaseDate = json['release_date']; rights = json['rights']; @@ -211,7 +216,7 @@ class EpisodeItem { skip = json['skip']; status = json['status']; subtitle = json['subtitle']; - title = json['title']; + title = json['long_title']; vid = json['vid']; } } diff --git a/lib/models/video_detail_res.dart b/lib/models/video_detail_res.dart index ae272375..3401d809 100644 --- a/lib/models/video_detail_res.dart +++ b/lib/models/video_detail_res.dart @@ -377,6 +377,7 @@ class Part { int? page; String? from; String? pagePart; + String? title; int? duration; String? vid; String? weblink; @@ -389,6 +390,7 @@ class Part { this.page, this.from, this.pagePart, + this.title, this.duration, this.vid, this.weblink, @@ -406,6 +408,7 @@ class Part { page = json["page"]; from = json["from"]; pagePart = json["part"]; + title = json["part"]; duration = json["duration"]; vid = json["vid"]; weblink = json["weblink"]; @@ -649,6 +652,9 @@ class EpisodeItem { Part? page; String? bvid; String? cover; + int? pubdate; + int? duration; + Stat? stat; EpisodeItem.fromJson(Map json) { seasonId = json['season_id']; @@ -661,6 +667,9 @@ class EpisodeItem { page = Part.fromJson(json['page']); bvid = json['bvid']; cover = json['arc']['pic']; + pubdate = json['arc']['pubdate']; + duration = json['arc']['duration']; + stat = Stat.fromJson(json['arc']['stat']); } } diff --git a/lib/pages/bangumi/introduction/controller.dart b/lib/pages/bangumi/introduction/controller.dart index 208a85e4..cf428c28 100644 --- a/lib/pages/bangumi/introduction/controller.dart +++ b/lib/pages/bangumi/introduction/controller.dart @@ -302,14 +302,13 @@ class BangumiIntroController extends GetxController { episodes: episodes, currentCid: videoDetailCtr.cid.value, dataType: dataType, - context: Get.context!, sheetHeight: Get.size.height, isFullScreen: true, changeFucCall: (item, index) { changeSeasonOrbangu(item.bvid, item.cid, item.aid, item.cover); SmartDialog.dismiss(); }, - ).buildShowContent(Get.context!), + ).buildShowContent(), ); } diff --git a/lib/pages/bangumi/widgets/bangumi_panel.dart b/lib/pages/bangumi/widgets/bangumi_panel.dart index 3a589db6..3cb9abc0 100644 --- a/lib/pages/bangumi/widgets/bangumi_panel.dart +++ b/lib/pages/bangumi/widgets/bangumi_panel.dart @@ -151,7 +151,6 @@ class _BangumiPanelState extends State { changeFucCall: changeFucCall, sheetHeight: widget.sheetHeight, dataType: VideoEpidoesType.bangumiEpisode, - context: context, ).show(context); }, child: Text( diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 0285225a..2c1e47eb 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -62,6 +62,7 @@ class VideoIntroController extends GetxController { late ModelResult modelResult; PersistentBottomSheetController? bottomSheetController; late bool enableRelatedVideo; + UgcSeason? ugcSeason; @override void onInit() { @@ -87,6 +88,7 @@ class VideoIntroController extends GetxController { var result = await VideoHttp.videoIntro(bvid: bvid); if (result['status']) { videoDetail.value = result['data']!; + ugcSeason = result['data']!.ugcSeason; if (videoDetail.value.pages!.isNotEmpty && lastPlayCid.value == 0) { lastPlayCid.value = videoDetail.value.pages!.first.cid!; } @@ -608,9 +610,9 @@ class VideoIntroController extends GetxController { episodes: episodes, currentCid: lastPlayCid.value, dataType: dataType, - context: Get.context!, sheetHeight: Get.size.height, isFullScreen: true, + ugcSeason: ugcSeason, changeFucCall: (item, index) { if (dataType == VideoEpidoesType.videoEpisode) { changeSeasonOrbangu( @@ -621,7 +623,7 @@ class VideoIntroController extends GetxController { } SmartDialog.dismiss(); }, - ).buildShowContent(Get.context!), + ).buildShowContent(), ); } diff --git a/lib/pages/video/detail/introduction/widgets/page_panel.dart b/lib/pages/video/detail/introduction/widgets/page_panel.dart index 266f5566..81a22176 100644 --- a/lib/pages/video/detail/introduction/widgets/page_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/page_panel.dart @@ -116,7 +116,6 @@ class _PagesPanelState extends State { changeFucCall: changeFucCall, sheetHeight: widget.sheetHeight, dataType: VideoEpidoesType.videoPart, - context: context, ).show(context); }, child: Text( diff --git a/lib/pages/video/detail/introduction/widgets/season_panel.dart b/lib/pages/video/detail/introduction/widgets/season_panel.dart index fdfec6f9..f26885a7 100644 --- a/lib/pages/video/detail/introduction/widgets/season_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/season_panel.dart @@ -124,7 +124,7 @@ class _SeasonPanelState extends State { changeFucCall: changeFucCall, sheetHeight: widget.sheetHeight, dataType: VideoEpidoesType.videoEpisode, - context: context, + ugcSeason: widget.ugcSeason, ).show(context); }, child: Padding(