diff --git a/assets/images/pay/alipay.jpg b/assets/images/pay/alipay.jpg new file mode 100644 index 00000000..1c1fc4c6 Binary files /dev/null and b/assets/images/pay/alipay.jpg differ diff --git a/assets/images/pay/wechat.png b/assets/images/pay/wechat.png new file mode 100644 index 00000000..3aa3a6a2 Binary files /dev/null and b/assets/images/pay/wechat.png differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 040c0297..86cb7071 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -29,6 +29,8 @@ PODS: - Flutter - GT3Captcha-iOS - GT3Captcha-iOS (0.15.8.3) + - image_picker_ios (0.0.1): + - Flutter - media_kit_libs_ios_video (1.0.4): - Flutter - media_kit_native_event_loop (1.0.0): @@ -80,6 +82,7 @@ DEPENDENCIES: - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - gt3_flutter_plugin (from `.symlinks/plugins/gt3_flutter_plugin/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) @@ -129,6 +132,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/fluttertoast/ios" gt3_flutter_plugin: :path: ".symlinks/plugins/gt3_flutter_plugin/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" media_kit_libs_ios_video: :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" media_kit_native_event_loop: @@ -179,6 +184,7 @@ SPEC CHECKSUMS: FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a gt3_flutter_plugin: 5bd2c08d3c19cbb6ee3b08f4358439e54c8ab2ee GT3Captcha-iOS: 5e3b1077834d8a9d6f4d64a447a30af3e14affe6 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e diff --git a/lib/common/constants.dart b/lib/common/constants.dart index cac13688..0607206c 100644 --- a/lib/common/constants.dart +++ b/lib/common/constants.dart @@ -15,6 +15,4 @@ class Constants { // 59b43e04ad6965f34319062b478f83dd TV端 static const String appSec = '59b43e04ad6965f34319062b478f83dd'; static const String thirdSign = '04224646d1fea004e79606d3b038c84a'; - static const String thirdApi = - 'https://www.mcbbs.net/template/mcbbs/image/special_photo_bg.png'; } diff --git a/lib/common/pages_bottom_sheet.dart b/lib/common/pages_bottom_sheet.dart index 49e9b4d8..d7872f13 100644 --- a/lib/common/pages_bottom_sheet.dart +++ b/lib/common/pages_bottom_sheet.dart @@ -1,35 +1,266 @@ 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:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:pilipala/models/video_detail_res.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'package:scrollview_observer/scrollview_observer.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 ScrollController _listScrollController = ScrollController(); + late ListObserverController _listObserverController; + final ScrollController _scrollController = ScrollController(); + late int currentIndex; + + @override + void initState() { + super.initState(); + currentIndex = + widget.episodes.indexWhere((dynamic e) => e.cid == widget.currentCid); + _listObserverController = + ListObserverController(controller: _listScrollController); + if (widget.dataType == VideoEpidoesType.videoEpisode) { + _listObserverController.initialIndexModel = ObserverIndexPositionModel( + index: currentIndex, + isFixedHeight: true, + ); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.dataType != VideoEpidoesType.videoEpisode) { + 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 + void dispose() { + _listObserverController.controller?.dispose(); + super.dispose(); + } + + @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 + ? ListViewObserver( + controller: _listObserverController, + child: ListView.builder( + controller: _listScrollController, + 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 +276,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.onInverseSurface.withOpacity(0.6), + 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/common/skeleton/media_bangumi.dart b/lib/common/skeleton/media_bangumi.dart index cf589254..98282cf0 100644 --- a/lib/common/skeleton/media_bangumi.dart +++ b/lib/common/skeleton/media_bangumi.dart @@ -3,14 +3,9 @@ import 'package:pilipala/common/constants.dart'; import 'skeleton.dart'; -class MediaBangumiSkeleton extends StatefulWidget { +class MediaBangumiSkeleton extends StatelessWidget { const MediaBangumiSkeleton({super.key}); - @override - State createState() => _MediaBangumiSkeletonState(); -} - -class _MediaBangumiSkeletonState extends State { @override Widget build(BuildContext context) { Color bgColor = Theme.of(context).colorScheme.onInverseSurface; @@ -35,25 +30,25 @@ class _MediaBangumiSkeletonState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - color: Theme.of(context).colorScheme.onInverseSurface, + color: bgColor, width: 200, height: 20, margin: const EdgeInsets.only(bottom: 15), ), Container( - color: Theme.of(context).colorScheme.onInverseSurface, + color: bgColor, width: 150, height: 13, margin: const EdgeInsets.only(bottom: 5), ), Container( - color: Theme.of(context).colorScheme.onInverseSurface, + color: bgColor, width: 150, height: 13, margin: const EdgeInsets.only(bottom: 5), ), Container( - color: Theme.of(context).colorScheme.onInverseSurface, + color: bgColor, width: 150, height: 13, ), @@ -64,7 +59,7 @@ class _MediaBangumiSkeletonState extends State { decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(20)), - color: Theme.of(context).colorScheme.onInverseSurface, + color: bgColor, ), ), ], diff --git a/lib/common/skeleton/user_list.dart b/lib/common/skeleton/user_list.dart new file mode 100644 index 00000000..cd9d4eb3 --- /dev/null +++ b/lib/common/skeleton/user_list.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import '../constants.dart'; + +class UserListSkeleton extends StatelessWidget { + const UserListSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + Color bgColor = Theme.of(context).colorScheme.onInverseSurface; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, vertical: 7), + child: Row( + children: [ + ClipOval( + child: Container(width: 42, height: 42, color: bgColor), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container(color: bgColor, width: 60, height: 13), + const SizedBox(width: 10), + Container(color: bgColor, width: 40, height: 13), + ], + ), + const SizedBox(height: 6), + Container( + color: bgColor, + width: 100, + height: 13, + ), + ], + ), + ), + ], + )); + } +} diff --git a/lib/common/widgets/http_error.dart b/lib/common/widgets/http_error.dart index 0381319e..51396c0b 100644 --- a/lib/common/widgets/http_error.dart +++ b/lib/common/widgets/http_error.dart @@ -4,9 +4,10 @@ import 'package:flutter_svg/flutter_svg.dart'; class HttpError extends StatelessWidget { const HttpError({ required this.errMsg, - required this.fn, + this.fn, this.btnText, this.isShowBtn = true, + this.isInSliver = true, super.key, }); @@ -14,46 +15,41 @@ class HttpError extends StatelessWidget { final Function()? fn; final String? btnText; final bool isShowBtn; + final bool isInSliver; @override Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: SizedBox( - height: 400, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - "assets/images/error.svg", - height: 200, - ), - const SizedBox(height: 30), - Text( - errMsg ?? '请求异常', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 20), - if (isShowBtn) - FilledButton.tonal( - onPressed: () { - fn!(); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.resolveWith((states) { - return Theme.of(context).colorScheme.primary.withAlpha(20); - }), - ), - child: Text( - btnText ?? '点击重试', - style: - TextStyle(color: Theme.of(context).colorScheme.primary), - ), + Color primary = Theme.of(context).colorScheme.primary; + final errorContent = SizedBox( + height: 400, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset("assets/images/error.svg", height: 200), + const SizedBox(height: 30), + Text( + errMsg ?? '请求异常', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 20), + if (isShowBtn) + FilledButton.tonal( + onPressed: () => fn?.call(), + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((states) { + return primary.withAlpha(20); + }), ), - ], - ), + child: Text(btnText ?? '点击重试', style: TextStyle(color: primary)), + ), + ], ), ); + if (isInSliver) { + return SliverToBoxAdapter(child: errorContent); + } else { + return Align(alignment: Alignment.topCenter, child: errorContent); + } } } diff --git a/lib/common/widgets/network_img_layer.dart b/lib/common/widgets/network_img_layer.dart index fbedfbba..d5903b71 100644 --- a/lib/common/widgets/network_img_layer.dart +++ b/lib/common/widgets/network_img_layer.dart @@ -20,6 +20,7 @@ class NetworkImgLayer extends StatelessWidget { // 图片质量 默认1% this.quality, this.origAspectRatio, + this.radius, }); final String? src; @@ -30,6 +31,18 @@ class NetworkImgLayer extends StatelessWidget { final Duration? fadeInDuration; final int? quality; final double? origAspectRatio; + final double? radius; + + BorderRadius getBorderRadius(String? type, double? radius) { + return BorderRadius.circular( + radius ?? + (type == 'avatar' + ? 50 + : type == 'emote' + ? 0 + : StyleString.imgRadius.x), + ); + } @override Widget build(BuildContext context) { @@ -72,13 +85,7 @@ class NetworkImgLayer extends StatelessWidget { return src != '' && src != null ? ClipRRect( clipBehavior: Clip.antiAlias, - borderRadius: BorderRadius.circular( - type == 'avatar' - ? 50 - : type == 'emote' - ? 0 - : StyleString.imgRadius.x, - ), + borderRadius: getBorderRadius(type, radius), child: CachedNetworkImage( imageUrl: imageUrl, width: width, @@ -107,11 +114,7 @@ class NetworkImgLayer extends StatelessWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Theme.of(context).colorScheme.onInverseSurface.withOpacity(0.4), - borderRadius: BorderRadius.circular(type == 'avatar' - ? 50 - : type == 'emote' - ? 0 - : StyleString.imgRadius.x), + borderRadius: getBorderRadius(type, radius), ), child: type == 'bg' ? const SizedBox() diff --git a/lib/http/api.dart b/lib/http/api.dart index d7d60160..13fb19c8 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -104,7 +104,7 @@ class Api { // 评论列表 // https://api.bilibili.com/x/v2/reply/main?csrf=6e22efc1a47225ea25f901f922b5cfdd&mode=3&oid=254175381&pagination_str=%7B%22offset%22:%22%22%7D&plat=1&seek_rpid=0&type=11 - static const String replyList = '/x/v2/reply'; + static const String replyList = '/x/v2/reply/main'; // 楼中楼 static const String replyReplyList = '/x/v2/reply/reply'; @@ -175,7 +175,7 @@ class Api { static const String delHistory = '/x/v2/history/delete'; // 搜索历史记录 - static const String searchHistory = '/x/web-goblin/history/search'; + static const String searchHistory = '/x/web-interface/history/search'; // 热搜 static const String hotSearchList = @@ -301,10 +301,6 @@ class Api { static const String bangumiList = '/pgc/season/index/result?st=1&order=3&season_version=-1&spoken_language_type=-1&area=-1&is_finish=-1©right=-1&season_status=-1&season_month=-1&year=-1&style_id=-1&sort=0&season_type=1&pagesize=20&type=1'; - // 我的订阅 - static const String bangumiFollow = - '/x/space/bangumi/follow/list?type=1&follow_status=0&pn=1&ps=15&ts=1691544359969'; - // 黑名单 static const String blackLst = '/x/relation/blacks'; @@ -499,7 +495,7 @@ class Api { static const activateBuvidApi = '/x/internal/gaia-gateway/ExClimbWuzhi'; /// 获取字幕配置 - static const getSubtitleConfig = '/x/player/v2'; + static const getSubtitleConfig = '/x/player/wbi/v2'; /// 我的订阅 static const userSubFolder = '/x/v3/fav/folder/collected/list'; @@ -593,6 +589,24 @@ class Api { static const String liveRoomEntry = '${HttpString.liveBaseUrl}/xlive/web-room/v1/index/roomEntryAction'; + /// 用户信息 + static const String accountInfo = '/x/member/web/account'; + + /// 更新用户信息 + static const String updateAccountInfo = '/x/member/web/update'; + /// 删除评论 static const String replyDel = '/x/v2/reply/del'; + + /// 图片上传 + static const String uploadImage = '/x/dynamic/feed/draw/upload_bfs'; + + /// 更新追番状态 + static const String updateBangumiStatus = '/pgc/web/follow/status/update'; + + /// 番剧点赞投币收藏状态 + static const String bangumiActionStatus = '/pgc/season/episode/community'; + + /// @我的 + static const String messageAtAPi = '/x/msgfeed/at?'; } diff --git a/lib/http/bangumi.dart b/lib/http/bangumi.dart index 91508682..d0c052d6 100644 --- a/lib/http/bangumi.dart +++ b/lib/http/bangumi.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import '../models/bangumi/list.dart'; import 'index.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:html/dom.dart' as html_dom; class BangumiHttp { static Future bangumiList({int? page}) async { @@ -18,8 +21,19 @@ class BangumiHttp { } } - static Future bangumiFollow({int? mid}) async { - var res = await Request().get(Api.bangumiFollow, data: {'vmid': mid}); + static Future getRecentBangumi({ + int? mid, + int type = 1, + int pn = 1, + int ps = 20, + }) async { + var res = await Request().get(Api.getRecentBangumiApi, data: { + 'vmid': mid, + 'type': type, + 'follow_status': 0, + 'pn': pn, + 'ps': ps, + }); if (res.data['code'] == 0) { return { 'status': true, @@ -33,4 +47,62 @@ class BangumiHttp { }; } } + + // 获取追番状态 + static Future bangumiStatus({required int seasonId}) async { + var res = await Request() + .get('https://www.bilibili.com/bangumi/play/ss$seasonId'); + html_dom.Document document = html_parser.parse(res.data); + // 查找 id 为 __NEXT_DATA__ 的 script 元素 + html_dom.Element? scriptElement = + document.querySelector('script#\\__NEXT_DATA__'); + if (scriptElement != null) { + // 提取 script 元素的内容 + String scriptContent = scriptElement.text; + final dynamic scriptContentJson = jsonDecode(scriptContent); + Map followState = scriptContentJson['props']['pageProps']['followState']; + return { + 'status': true, + 'data': { + 'isFollowed': followState['isFollowed'], + 'followStatus': followState['followStatus'] + } + }; + } else { + print('Script element with id "__NEXT_DATA__" not found.'); + } + } + + // 更新追番状态 + static Future updateBangumiStatus({ + required int seasonId, + required int status, + }) async { + var res = await Request().post(Api.updateBangumiStatus, data: { + 'season_id': seasonId, + 'status': status, + }); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } + + // 获取番剧点赞投币收藏状态 + static Future bangumiActionStatus({required int epId}) async { + var res = await Request().get( + Api.bangumiActionStatus, + data: {'ep_id': epId}, + ); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'data': [], 'msg': res.data['message']}; + } + } } diff --git a/lib/http/html.dart b/lib/http/html.dart index 100887e5..87adacb9 100644 --- a/lib/http/html.dart +++ b/lib/http/html.dart @@ -21,7 +21,6 @@ class HtmlHttp { } try { Document rootTree = parse(response.data); - // log(response.data.body.toString()); Element body = rootTree.body!; Element appDom = body.querySelector('#app')!; Element authorHeader = appDom.querySelector('.fixed-author-header')!; @@ -52,7 +51,6 @@ class HtmlHttp { .className .split(' ')[1] .split('-')[2]; - // List imgList = opusDetail.querySelectorAll('bili-album__preview__picture__img'); return { 'status': true, 'avatar': avatar, @@ -76,20 +74,10 @@ class HtmlHttp { Element body = rootTree.body!; Element appDom = body.querySelector('#app')!; Element authorHeader = appDom.querySelector('.up-left')!; - // 头像 - // String avatar = - // authorHeader.querySelector('.bili-avatar-img')!.attributes['data-src']!; - // print(avatar); - // avatar = 'https:${avatar.split('@')[0]}'; String uname = authorHeader.querySelector('.up-name')!.text.trim(); // 动态详情 Element opusDetail = appDom.querySelector('.article-content')!; // 发布时间 - // String updateTime = - // opusDetail.querySelector('.opus-module-author__pub__text')!.text; - // print(updateTime); - - // String opusContent = opusDetail.querySelector('#read-article-holder')!.innerHtml; RegExp digitRegExp = RegExp(r'\d+'); diff --git a/lib/http/init.dart b/lib/http/init.dart index abe8d019..3117666e 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -8,7 +8,6 @@ import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; -// import 'package:dio_http2_adapter/dio_http2_adapter.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/utils/id_utils.dart'; import '../utils/storage.dart'; @@ -171,15 +170,6 @@ class Request { dio = Dio(options); - /// fix 第三方登录 302重定向 跟iOS代理问题冲突 - // ..httpClientAdapter = Http2Adapter( - // ConnectionManager( - // idleTimeout: const Duration(milliseconds: 10000), - // onClientCreate: (_, ClientSetting config) => - // config.onBadCertificate = (_) => true, - // ), - // ); - /// 设置代理 if (enableSystemProxy) { dio.httpClientAdapter = IOHttpClientAdapter( @@ -247,11 +237,26 @@ class Request { } } + /* + * get请求 + */ + getWithoutCookie(url, {data}) { + return get( + url, + data: data, + options: Options( + headers: { + 'cookie': 'buvid3= ; b_nut= ; sid= ', + 'user-agent': headerUa(type: 'pc'), + }, + ), + ); + } + /* * post请求 */ post(url, {data, queryParameters, options, cancelToken, extra}) async { - // print('post-data: $data'); Response response; try { response = await dio.post( @@ -262,7 +267,6 @@ class Request { options ?? Options(contentType: Headers.formUrlEncodedContentType), cancelToken: cancelToken, ); - // print('post success: ${response.data}'); return response; } on DioException catch (e) { Response errResponse = Response( @@ -318,7 +322,7 @@ class Request { } } else { headerUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15'; + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; } return headerUa; } diff --git a/lib/http/interceptor.dart b/lib/http/interceptor.dart index 259a3bf9..b33d18df 100644 --- a/lib/http/interceptor.dart +++ b/lib/http/interceptor.dart @@ -3,8 +3,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:hive/hive.dart'; -import '../utils/storage.dart'; +import 'package:pilipala/utils/login.dart'; class ApiInterceptor extends Interceptor { @override @@ -19,20 +18,9 @@ class ApiInterceptor extends Interceptor { @override void onResponse(Response response, ResponseInterceptorHandler handler) { try { - if (response.statusCode == 302) { - final List locations = response.headers['location']!; - if (locations.isNotEmpty) { - if (locations.first.startsWith('https://www.mcbbs.net')) { - final Uri uri = Uri.parse(locations.first); - final String? accessKey = uri.queryParameters['access_key']; - final String? mid = uri.queryParameters['mid']; - try { - Box localCache = GStrorage.localCache; - localCache.put(LocalCacheKey.accessKey, - {'mid': mid, 'value': accessKey}); - } catch (_) {} - } - } + // 在响应之后处理数据 + if (response.data is Map && response.data['code'] == -101) { + LoginUtils.loginOut(); } } catch (err) { print('ApiInterceptor: $err'); diff --git a/lib/http/msg.dart b/lib/http/msg.dart index 6426a6f2..65156e03 100644 --- a/lib/http/msg.dart +++ b/lib/http/msg.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:pilipala/models/msg/at.dart'; import 'package:pilipala/models/msg/like.dart'; import 'package:pilipala/models/msg/reply.dart'; import 'package:pilipala/models/msg/system.dart'; @@ -63,7 +65,7 @@ class MsgHttp { .toList(), }; } catch (err) { - print('err🔟: $err'); + debugPrint('err: $err'); } } else { return { @@ -278,10 +280,10 @@ class MsgHttp { 'data': MessageLikeModel.fromJson(res.data['data']), }; } catch (err) { - return {'status': false, 'date': [], 'msg': err.toString()}; + return {'status': false, 'data': [], 'msg': err.toString()}; } } else { - return {'status': false, 'date': [], 'msg': res.data['message']}; + return {'status': false, 'data': [], 'msg': res.data['message']}; } } @@ -349,4 +351,24 @@ class MsgHttp { return {'status': false, 'date': [], 'msg': res.data['message']}; } } + + // @我的 + static Future messageAt() async { + var res = await Request().get(Api.messageAtAPi, data: { + 'build': 0, + 'mobi_app': 'web', + }); + if (res.data['code'] == 0) { + try { + return { + 'status': true, + 'data': MessageAtModel.fromJson(res.data['data']), + }; + } catch (err) { + return {'status': false, 'data': [], 'msg': err.toString()}; + } + } else { + return {'status': false, 'data': [], 'msg': res.data['message']}; + } + } } diff --git a/lib/http/reply.dart b/lib/http/reply.dart index c07d9e81..846ef45b 100644 --- a/lib/http/reply.dart +++ b/lib/http/reply.dart @@ -1,3 +1,8 @@ +import 'dart:convert'; + +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:image_picker/image_picker.dart'; import '../models/video/reply/data.dart'; import '../models/video/reply/emote.dart'; import 'api.dart'; @@ -6,17 +11,16 @@ import 'init.dart'; class ReplyHttp { static Future replyList({ required int oid, - required int pageNum, + required String nextOffset, required int type, int? ps, int sort = 1, }) async { var res = await Request().get(Api.replyList, data: { 'oid': oid, - 'pn': pageNum, 'type': type, - 'sort': sort, - 'ps': ps ?? 20 + 'pagination_str': jsonEncode({'offset': nextOffset}), + 'mode': sort + 2, }); if (res.data['code'] == 0) { return { @@ -52,19 +56,13 @@ class ReplyHttp { if (res.data['code'] == 0) { return { 'status': true, - 'data': ReplyData.fromJson(res.data['data']), + 'data': ReplyReplyData.fromJson(res.data['data']), }; } else { - Map errMap = { - -400: '请求错误', - -404: '无此项', - 12002: '评论区已关闭', - 12009: '评论主体的type不合法', - }; return { 'status': false, 'date': [], - 'msg': errMap[res.data['code']] ?? '请求异常', + 'msg': res.data['message'], }; } } @@ -136,4 +134,44 @@ class ReplyHttp { return {'status': false, 'msg': res.data['message']}; } } + + // 图片上传 + static Future uploadImage( + {required XFile xFile, String type = 'new_dyn'}) async { + var formData = FormData.fromMap({ + 'file_up': await xFileToMultipartFile(xFile), + 'biz': type, + 'csrf': await Request.getCsrf(), + 'category': 'daily', + }); + var res = await Request().post( + Api.uploadImage, + data: formData, + ); + if (res.data['code'] == 0) { + var data = res.data['data']; + data['img_src'] = data['image_url']; + data['img_width'] = data['image_width']; + data['img_height'] = data['image_height']; + data.remove('image_url'); + data.remove('image_width'); + data.remove('image_height'); + return { + 'status': true, + 'data': data, + }; + } else { + return { + 'status': false, + 'date': [], + 'msg': res.data['message'], + }; + } + } + + static Future xFileToMultipartFile(XFile xFile) async { + var file = File(xFile.path); + var bytes = await file.readAsBytes(); + return MultipartFile.fromBytes(bytes, filename: xFile.name); + } } diff --git a/lib/http/user.dart b/lib/http/user.dart index 26b79523..99888aea 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -1,10 +1,8 @@ import 'dart:convert'; -import 'dart:developer'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:dio/dio.dart'; import 'package:html/parser.dart'; import 'package:pilipala/models/video/later.dart'; -import '../common/constants.dart'; import '../models/model_hot_video_item.dart'; import '../models/user/fav_detail.dart'; import '../models/user/fav_folder.dart'; @@ -218,25 +216,6 @@ class UserHttp { } } - // 获取用户凭证 失效 - static Future thirdLogin() async { - var res = await Request().get( - 'https://passport.bilibili.com/login/app/third', - data: { - 'appkey': Constants.appKey, - 'api': Constants.thirdApi, - 'sign': Constants.thirdSign, - }, - ); - try { - if (res.data['code'] == 0 && res.data['data']['has_login'] == 1) { - Request().get(res.data['data']['confirm_uri']); - } - } catch (err) { - SmartDialog.showNotify(msg: '获取用户凭证: $err', notifyType: NotifyType.error); - } - } - // 清空稍后再看 static Future toViewClear() async { var res = await Request().post( @@ -283,30 +262,6 @@ class UserHttp { return {'status': false, 'msg': res.data['message']}; } } - // // 相互关系查询 - // static Future relationSearch(int mid) async { - // Map params = await WbiSign().makSign({ - // 'mid': mid, - // 'token': '', - // 'platform': 'web', - // 'web_location': 1550101, - // }); - // var res = await Request().get( - // Api.relationSearch, - // data: { - // 'mid': mid, - // 'w_rid': params['w_rid'], - // 'wts': params['wts'], - // }, - // ); - // if (res.data['code'] == 0) { - // // relation 主动状态 - // // 被动状态 - // return {'status': true, 'data': res.data['data']}; - // } else { - // return {'status': false, 'msg': res.data['message']}; - // } - // } // 搜索历史记录 static Future searchHistory( @@ -436,31 +391,6 @@ class UserHttp { } } - // 稍后再看播放全部 - // static Future toViewPlayAll({required int oid, required String bvid}) async { - // var res = await Request().get( - // Api.watchLaterHtml, - // data: { - // 'oid': oid, - // 'bvid': bvid, - // }, - // ); - // String scriptContent = - // extractScriptContents(parse(res.data).body!.outerHtml)[0]; - // int startIndex = scriptContent.indexOf('{'); - // int endIndex = scriptContent.lastIndexOf('};'); - // String jsonContent = scriptContent.substring(startIndex, endIndex + 1); - // // 解析JSON字符串为Map - // Map jsonData = json.decode(jsonContent); - // // 输出解析后的数据 - // return { - // 'status': true, - // 'data': jsonData['resourceList'] - // .map((e) => MediaVideoItemModel.fromJson(e)) - // .toList() - // }; - // } - static List extractScriptContents(String htmlContent) { RegExp scriptRegExp = RegExp(r'