diff --git a/lib/pages/common/common_intro_controller.dart b/lib/pages/common/common_intro_controller.dart index 58ad2f29f..d35eca4c4 100644 --- a/lib/pages/common/common_intro_controller.dart +++ b/lib/pages/common/common_intro_controller.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Timer; +import 'dart:async' show FutureOr, Timer; import 'package:PiliPlus/http/fav.dart'; import 'package:PiliPlus/http/loading_state.dart'; @@ -38,6 +38,17 @@ abstract class CommonIntroController extends GetxController { final Rx?> videoTags = Rx?>(null); + bool get hasTriple => hasLike.value && hasCoin && hasFav.value; + + bool isProcessing = false; + Future handleAction(FutureOr Function() action) async { + if (!isProcessing) { + isProcessing = true; + await action(); + isProcessing = false; + } + } + Set? favIds; final Rx favFolderData = FavFolderData().obs; @@ -54,7 +65,7 @@ abstract class CommonIntroController extends GetxController { bool prevPlay(); bool nextPlay(); - void actionLikeVideo(); + Future actionLikeVideo(); void actionCoinVideo(); void actionTriple(); void actionShareVideo(BuildContext context); diff --git a/lib/pages/dynamics_mention/view.dart b/lib/pages/dynamics_mention/view.dart index 5d5f7bcc6..54fa5e72f 100644 --- a/lib/pages/dynamics_mention/view.dart +++ b/lib/pages/dynamics_mention/view.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/dynamics_mention/widgets/item.dart'; -import 'package:PiliPlus/pages/search/controller.dart' show SearchKeywordMixin; +import 'package:PiliPlus/pages/search/controller.dart' show SearchState; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; @@ -58,8 +58,7 @@ class DynMentionPanel extends StatefulWidget { State createState() => _DynMentionPanelState(); } -class _DynMentionPanelState extends State - with SearchKeywordMixin { +class _DynMentionPanelState extends SearchState { final _controller = Get.put(DynMentionController()); @override Duration get duration => const Duration(milliseconds: 300); @@ -70,13 +69,6 @@ class _DynMentionPanelState extends State if (_controller.loadingState.value is Error) { _controller.onReload(); } - subInit(); - } - - @override - void dispose() { - subDispose(); - super.dispose(); } @override diff --git a/lib/pages/dynamics_select_topic/view.dart b/lib/pages/dynamics_select_topic/view.dart index 7e54b795c..88cc41cb8 100644 --- a/lib/pages/dynamics_select_topic/view.dart +++ b/lib/pages/dynamics_select_topic/view.dart @@ -8,7 +8,7 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/widgets/item.dart'; -import 'package:PiliPlus/pages/search/controller.dart' show SearchKeywordMixin; +import 'package:PiliPlus/pages/search/controller.dart' show SearchState; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:flutter/material.dart'; @@ -56,8 +56,7 @@ class SelectTopicPanel extends StatefulWidget { State createState() => _SelectTopicPanelState(); } -class _SelectTopicPanelState extends State - with SearchKeywordMixin { +class _SelectTopicPanelState extends SearchState { final _controller = Get.put(SelectTopicController()); @override Duration get duration => const Duration(milliseconds: 300); @@ -68,13 +67,6 @@ class _SelectTopicPanelState extends State if (_controller.loadingState.value is Error) { _controller.onReload(); } - subInit(); - } - - @override - void dispose() { - subDispose(); - super.dispose(); } @override diff --git a/lib/pages/search/controller.dart b/lib/pages/search/controller.dart index 6ed02928d..a571ee293 100644 --- a/lib/pages/search/controller.dart +++ b/lib/pages/search/controller.dart @@ -30,6 +30,23 @@ mixin SearchKeywordMixin { void subDispose() { sub?.cancel(); ctr?.close(); + sub = null; + ctr = null; + } +} + +abstract class SearchState extends State + with SearchKeywordMixin { + @override + void dispose() { + subDispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + subInit(); } } diff --git a/lib/pages/settings_search/view.dart b/lib/pages/settings_search/view.dart index 8bf5d766c..ecfc2b1cf 100644 --- a/lib/pages/settings_search/view.dart +++ b/lib/pages/settings_search/view.dart @@ -1,5 +1,5 @@ import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; -import 'package:PiliPlus/pages/search/controller.dart' show SearchKeywordMixin; +import 'package:PiliPlus/pages/search/controller.dart' show SearchState; import 'package:PiliPlus/pages/setting/models/extra_settings.dart'; import 'package:PiliPlus/pages/setting/models/model.dart'; import 'package:PiliPlus/pages/setting/models/play_settings.dart'; @@ -19,8 +19,7 @@ class SettingsSearchPage extends StatefulWidget { State createState() => _SettingsSearchPageState(); } -class _SettingsSearchPageState extends State - with SearchKeywordMixin { +class _SettingsSearchPageState extends SearchState { final _textEditingController = TextEditingController(); final RxList _list = [].obs; late final _settings = [ @@ -32,12 +31,6 @@ class _SettingsSearchPageState extends State ...styleSettings, ]; - @override - void initState() { - super.initState(); - subInit(); - } - @override void onKeywordChanged(String value) { if (value.isEmpty) { @@ -58,7 +51,6 @@ class _SettingsSearchPageState extends State @override void dispose() { - subDispose(); _textEditingController.dispose(); super.dispose(); } diff --git a/lib/pages/video/introduction/pgc/view.dart b/lib/pages/video/introduction/pgc/view.dart index 9bf678905..8b1a9036e 100644 --- a/lib/pages/video/introduction/pgc/view.dart +++ b/lib/pages/video/introduction/pgc/view.dart @@ -14,6 +14,7 @@ import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/widgets/pgc_panel.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart'; +import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_state.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:flutter/material.dart'; @@ -39,11 +40,7 @@ class PgcIntroPage extends StatefulWidget { } class _PgcIntroPageState extends State - with - AutomaticKeepAliveClientMixin, - SingleTickerProviderStateMixin, - TripleAnimMixin { - @override + with AutomaticKeepAliveClientMixin { late PgcIntroController introController; late VideoDetailController videoDetailCtr; @@ -407,68 +404,75 @@ class _PgcIntroPageState extends State ) { return SizedBox( height: 48, - child: Row( - children: [ - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.thumbsUp), - selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), - onTap: () => handleAction(introController.actionLikeVideo), - selectStatus: introController.hasLike.value, - semanticsLabel: '点赞', - text: NumUtil.numFormat(item.stat!.like), - controller: animController, - animation: animation, - onStartTriple: onStartTriple, - onCancelTriple: onCancelTriple, - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.b), - selectIcon: const Icon(FontAwesomeIcons.b), - onTap: introController.actionCoinVideo, - selectStatus: introController.hasCoin, - semanticsLabel: '投币', - text: NumUtil.numFormat(item.stat!.coin), - controller: animController, - animation: animation, - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.star), - selectIcon: const Icon(FontAwesomeIcons.solidStar), - onTap: () => introController.showFavBottomSheet(context), - onLongPress: () => introController.showFavBottomSheet( - context, - isLongPress: true, + child: TripleBuilder( + introController: introController, + builder: (context, tripleAnimation, onStartTriple, onCancelTriple) { + return Row( + children: [ + Obx( + () => ActionItem( + animation: tripleAnimation, + icon: const Icon(FontAwesomeIcons.thumbsUp), + selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), + onTap: () => introController.handleAction( + introController.actionLikeVideo, + ), + selectStatus: introController.hasLike.value, + semanticsLabel: '点赞', + text: NumUtil.numFormat(item.stat!.like), + onStartTriple: onStartTriple, + onCancelTriple: onCancelTriple, + ), ), - selectStatus: introController.hasFav.value, - semanticsLabel: '收藏', - text: NumUtil.numFormat(item.stat!.favorite), - controller: animController, - animation: animation, - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.clock), - selectIcon: const Icon(FontAwesomeIcons.solidClock), - onTap: () => handleAction(introController.viewLater), - selectStatus: introController.hasLater.value, - semanticsLabel: '再看', - text: '再看', - ), - ), - ActionItem( - icon: const Icon(FontAwesomeIcons.shareFromSquare), - onTap: () => introController.actionShareVideo(context), - selectStatus: false, - semanticsLabel: '转发', - text: NumUtil.numFormat(item.stat!.share), - ), - ], + Obx( + () => ActionItem( + animation: tripleAnimation, + icon: const Icon(FontAwesomeIcons.b), + selectIcon: const Icon(FontAwesomeIcons.b), + onTap: () => introController.handleAction( + introController.actionCoinVideo, + ), + selectStatus: introController.hasCoin, + semanticsLabel: '投币', + text: NumUtil.numFormat(item.stat!.coin), + ), + ), + Obx( + () => ActionItem( + animation: tripleAnimation, + icon: const Icon(FontAwesomeIcons.star), + selectIcon: const Icon(FontAwesomeIcons.solidStar), + onTap: () => introController.showFavBottomSheet(context), + onLongPress: () => introController.showFavBottomSheet( + context, + isLongPress: true, + ), + selectStatus: introController.hasFav.value, + semanticsLabel: '收藏', + text: NumUtil.numFormat(item.stat!.favorite), + ), + ), + Obx( + () => ActionItem( + icon: const Icon(FontAwesomeIcons.clock), + selectIcon: const Icon(FontAwesomeIcons.solidClock), + onTap: () => + introController.handleAction(introController.viewLater), + selectStatus: introController.hasLater.value, + semanticsLabel: '再看', + text: '再看', + ), + ), + ActionItem( + icon: const Icon(FontAwesomeIcons.shareFromSquare), + onTap: () => introController.actionShareVideo(context), + selectStatus: false, + semanticsLabel: '转发', + text: NumUtil.numFormat(item.stat!.share), + ), + ], + ); + }, ), ); } diff --git a/lib/pages/video/introduction/ugc/controller.dart b/lib/pages/video/introduction/ugc/controller.dart index c66114d19..6b52ccbc1 100644 --- a/lib/pages/video/introduction/ugc/controller.dart +++ b/lib/pages/video/introduction/ugc/controller.dart @@ -269,7 +269,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { // 投币 @override - void actionCoinVideo() { + Future actionCoinVideo() async { if (!accountService.isLogin.value) { SmartDialog.showToast('账号未登录'); return; diff --git a/lib/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index 21fc55db6..42609be6f 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -15,6 +15,7 @@ import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/season.dart'; +import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_state.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/date_util.dart'; @@ -51,11 +52,7 @@ class UgcIntroPanel extends StatefulWidget { } class _UgcIntroPanelState extends State - with - AutomaticKeepAliveClientMixin, - SingleTickerProviderStateMixin, - TripleAnimMixin { - @override + with AutomaticKeepAliveClientMixin { late UgcIntroController introController; late final VideoDetailController videoDetailCtr = Get.find(tag: widget.heroTag); @@ -509,86 +506,95 @@ class _UgcIntroPanelState extends State ) { return SizedBox( height: 48, - child: Row( - children: [ - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.thumbsUp), - selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), - onTap: () => handleAction(introController.actionLikeVideo), - selectStatus: introController.hasLike.value, - semanticsLabel: '点赞', - text: !isLoading - ? NumUtil.numFormat(videoDetail.stat!.like) - : null, - controller: animController, - animation: animation, - onStartTriple: onStartTriple, - onCancelTriple: onCancelTriple, - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.thumbsDown), - selectIcon: const Icon(FontAwesomeIcons.solidThumbsDown), - onTap: () => handleAction(introController.actionDislikeVideo), - selectStatus: introController.hasDislike.value, - semanticsLabel: '点踩', - text: "点踩", - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.b), - selectIcon: const Icon(FontAwesomeIcons.b), - onTap: introController.actionCoinVideo, - selectStatus: introController.hasCoin, - semanticsLabel: '投币', - text: !isLoading - ? NumUtil.numFormat(videoDetail.stat!.coin) - : null, - controller: animController, - animation: animation, - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.star), - selectIcon: const Icon(FontAwesomeIcons.solidStar), - onTap: () => introController.showFavBottomSheet(context), - onLongPress: () => introController.showFavBottomSheet( - context, - isLongPress: true, + child: TripleBuilder( + introController: introController, + builder: (context, tripleAnimation, onStartTriple, onCancelTriple) { + return Row( + children: [ + Obx( + () => ActionItem( + animation: tripleAnimation, + icon: const Icon(FontAwesomeIcons.thumbsUp), + selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), + onTap: () => introController.handleAction( + introController.actionLikeVideo, + ), + selectStatus: introController.hasLike.value, + semanticsLabel: '点赞', + text: !isLoading + ? NumUtil.numFormat(videoDetail.stat!.like) + : null, + onStartTriple: onStartTriple, + onCancelTriple: onCancelTriple, + ), ), - selectStatus: introController.hasFav.value, - semanticsLabel: '收藏', - text: !isLoading - ? NumUtil.numFormat(videoDetail.stat!.favorite) - : null, - controller: animController, - animation: animation, - ), - ), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.clock), - selectIcon: const Icon(FontAwesomeIcons.solidClock), - onTap: () => handleAction(introController.viewLater), - selectStatus: introController.hasLater.value, - semanticsLabel: '再看', - text: '再看', - ), - ), - ActionItem( - icon: const Icon(FontAwesomeIcons.shareFromSquare), - onTap: () => introController.actionShareVideo(context), - selectStatus: false, - semanticsLabel: '分享', - text: !isLoading - ? NumUtil.numFormat(videoDetail.stat!.share!) - : null, - ), - ], + Obx( + () => ActionItem( + icon: const Icon(FontAwesomeIcons.thumbsDown), + selectIcon: const Icon(FontAwesomeIcons.solidThumbsDown), + onTap: () => introController.handleAction( + introController.actionDislikeVideo, + ), + selectStatus: introController.hasDislike.value, + semanticsLabel: '点踩', + text: "点踩", + ), + ), + Obx( + () => ActionItem( + animation: tripleAnimation, + icon: const Icon(FontAwesomeIcons.b), + selectIcon: const Icon(FontAwesomeIcons.b), + onTap: () => introController.handleAction( + introController.actionCoinVideo, + ), + selectStatus: introController.hasCoin, + semanticsLabel: '投币', + text: !isLoading + ? NumUtil.numFormat(videoDetail.stat!.coin) + : null, + ), + ), + Obx( + () => ActionItem( + animation: tripleAnimation, + icon: const Icon(FontAwesomeIcons.star), + selectIcon: const Icon(FontAwesomeIcons.solidStar), + onTap: () => introController.showFavBottomSheet(context), + onLongPress: () => introController.showFavBottomSheet( + context, + isLongPress: true, + ), + selectStatus: introController.hasFav.value, + semanticsLabel: '收藏', + text: !isLoading + ? NumUtil.numFormat(videoDetail.stat!.favorite) + : null, + ), + ), + Obx( + () => ActionItem( + icon: const Icon(FontAwesomeIcons.clock), + selectIcon: const Icon(FontAwesomeIcons.solidClock), + onTap: () => + introController.handleAction(introController.viewLater), + selectStatus: introController.hasLater.value, + semanticsLabel: '再看', + text: '再看', + ), + ), + ActionItem( + icon: const Icon(FontAwesomeIcons.shareFromSquare), + onTap: () => introController.actionShareVideo(context), + selectStatus: false, + semanticsLabel: '分享', + text: !isLoading + ? NumUtil.numFormat(videoDetail.stat!.share!) + : null, + ), + ], + ); + }, ), ); } diff --git a/lib/pages/video/introduction/ugc/widgets/action_item.dart b/lib/pages/video/introduction/ugc/widgets/action_item.dart index 8159e73fe..1789a724a 100644 --- a/lib/pages/video/introduction/ugc/widgets/action_item.dart +++ b/lib/pages/video/introduction/ugc/widgets/action_item.dart @@ -1,95 +1,6 @@ -import 'dart:async' show Timer, FutureOr; import 'dart:math' show pi; -import 'package:PiliPlus/pages/common/common_intro_controller.dart'; -import 'package:PiliPlus/utils/feed_back.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show HapticFeedback; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; - -mixin TripleAnimMixin - on SingleTickerProviderStateMixin { - CommonIntroController get introController; - late AnimationController animController; - late Animation animation; - - late int _lastTime; - Timer? _timer; - - bool get _hasTriple => - introController.hasLike.value && - introController.hasCoin && - introController.hasFav.value; - - bool isProcessing = false; - Future handleAction(FutureOr Function() action) async { - if (!isProcessing) { - isProcessing = true; - await action(); - isProcessing = false; - } - } - - @override - void initState() { - super.initState(); - animController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - reverseDuration: const Duration(milliseconds: 400), - ); - - animation = Tween(begin: 0, end: -2 * pi).animate( - CurvedAnimation( - parent: animController, - curve: Curves.easeInOut, - ), - ); - } - - void onStartTriple() { - _lastTime = DateTime.now().millisecondsSinceEpoch; - _timer ??= Timer(const Duration(milliseconds: 200), () { - HapticFeedback.lightImpact(); - if (_hasTriple) { - SmartDialog.showToast('已经完成三连'); - } else { - animController.forward().whenComplete(() { - animController.reset(); - handleAction(introController.actionTriple); - }); - } - cancelTimer(); - }); - } - - void onCancelTriple(bool isCancel) { - int duration = DateTime.now().millisecondsSinceEpoch - _lastTime; - if (duration >= 200 && duration < 1500) { - if (!_hasTriple) { - animController.reverse(); - } - } else if (duration < 200) { - cancelTimer(); - if (!isCancel) { - feedBack(); - handleAction(introController.actionLikeVideo); - } - } - } - - void cancelTimer() { - _timer?.cancel(); - _timer = null; - } - - @override - void dispose() { - cancelTimer(); - animController.dispose(); - super.dispose(); - } -} class ActionItem extends StatelessWidget { const ActionItem({ @@ -102,11 +13,11 @@ class ActionItem extends StatelessWidget { this.selectStatus = false, required this.semanticsLabel, this.expand = true, - this.controller, this.animation, this.onStartTriple, this.onCancelTriple, - }) : isThumbsUp = onStartTriple != null; + }) : assert(!selectStatus || selectIcon != null), + _isThumbsUp = onStartTriple != null; final Icon icon; final Icon? selectIcon; @@ -116,87 +27,85 @@ class ActionItem extends StatelessWidget { final bool selectStatus; final String semanticsLabel; final bool expand; - final AnimationController? controller; final Animation? animation; final VoidCallback? onStartTriple; - final ValueChanged? onCancelTriple; - final bool isThumbsUp; + final void Function([bool])? onCancelTriple; + final bool _isThumbsUp; @override Widget build(BuildContext context) { final theme = Theme.of(context); - Widget? textWidget; - if (expand) { - final hasText = text != null; - textWidget = Text( - hasText ? text! : '-', - key: hasText ? ValueKey(text!) : null, - style: TextStyle( - color: selectStatus - ? theme.colorScheme.primary - : theme.colorScheme.outline, - fontSize: theme.textTheme.labelSmall!.fontSize, - ), - ); - if (hasText) { - textWidget = AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition(scale: animation, child: child); - }, - child: textWidget, - ); - } - } - final child = Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(6)), - onTap: isThumbsUp - ? null - : () { - feedBack(); - onTap?.call(); - }, - onLongPress: isThumbsUp ? null : onLongPress, - onTapDown: isThumbsUp ? (details) => onStartTriple!() : null, - onTapUp: isThumbsUp ? (details) => onCancelTriple!(false) : null, - onTapCancel: isThumbsUp ? () => onCancelTriple!(true) : null, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - if (animation != null) - AnimatedBuilder( - animation: animation!, - builder: (context, child) => CustomPaint( - size: const Size(28, 28), - painter: _ArcPainter( - color: theme.colorScheme.primary, - sweepAngle: animation!.value, - ), - ), - ) - else - const SizedBox(width: 28, height: 28), - Icon( - selectStatus ? selectIcon!.icon! : icon.icon, - size: 18, - color: selectStatus - ? theme.colorScheme.primary - : icon.color ?? theme.colorScheme.outline, - ), - ], + Widget child = Icon( + selectStatus ? selectIcon!.icon! : icon.icon, + size: 18, + color: selectStatus + ? theme.colorScheme.primary + : icon.color ?? theme.colorScheme.outline, + ); + + if (animation != null) { + child = Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: animation!, + builder: (context, child) => CustomPaint( + size: const Size.square(28), + painter: _ArcPainter( + color: theme.colorScheme.primary, + sweepAngle: animation!.value, + ), ), - ?textWidget, - ], - ), + ), + child, + ], + ); + } else { + child = SizedBox.square(dimension: 28, child: child); + } + + child = InkWell( + borderRadius: const BorderRadius.all(Radius.circular(6)), + onTap: _isThumbsUp ? null : onTap, + onLongPress: _isThumbsUp ? null : onLongPress, + onTapDown: _isThumbsUp ? (_) => onStartTriple!() : null, + onTapUp: _isThumbsUp ? (_) => onCancelTriple!(true) : null, + onTapCancel: _isThumbsUp ? onCancelTriple : null, + child: expand + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [child, _buildText(theme)], + ) + : child, + ); + return expand + ? Expanded(child: child) + : Material(type: MaterialType.transparency, child: child); + } + + Widget _buildText(ThemeData theme) { + final hasText = text != null; + final child = Text( + hasText ? text! : '-', + key: hasText ? ValueKey(text!) : null, + style: TextStyle( + color: selectStatus + ? theme.colorScheme.primary + : theme.colorScheme.outline, + fontSize: theme.textTheme.labelSmall!.fontSize, ), ); - return expand ? Expanded(child: child) : child; + if (hasText) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition(scale: animation, child: child); + }, + child: child, + ); + } + return child; } } diff --git a/lib/pages/video/introduction/ugc/widgets/menu_row.dart b/lib/pages/video/introduction/ugc/widgets/menu_row.dart index ad392a936..ac8a2d4b9 100644 --- a/lib/pages/video/introduction/ugc/widgets/menu_row.dart +++ b/lib/pages/video/introduction/ugc/widgets/menu_row.dart @@ -13,7 +13,7 @@ class ActionRowLineItem extends StatelessWidget { }); final bool selectStatus; final Function? onTap; - final bool? isLoading; + final bool isLoading; final String? text; final IconData? iconData; final Widget? icon; @@ -55,7 +55,7 @@ class ActionRowLineItem extends StatelessWidget { else if (icon != null) icon!, AnimatedOpacity( - opacity: isLoading! ? 0 : 1, + opacity: isLoading ? 0 : 1, duration: const Duration(milliseconds: 200), child: Text( text!, diff --git a/lib/pages/video/introduction/ugc/widgets/triple_state.dart b/lib/pages/video/introduction/ugc/widgets/triple_state.dart new file mode 100644 index 000000000..bd18397b9 --- /dev/null +++ b/lib/pages/video/introduction/ugc/widgets/triple_state.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:math' show pi; + +import 'package:PiliPlus/pages/common/common_intro_controller.dart'; +import 'package:PiliPlus/utils/feed_back.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; + +abstract class TripleState extends State + with SingleTickerProviderStateMixin { + late final tripleAnimCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + reverseDuration: const Duration(milliseconds: 400), + ); + + late final tripleAnimation = Tween( + begin: 0, + end: -2 * pi, + ).animate(CurvedAnimation(parent: tripleAnimCtr, curve: Curves.easeInOut)); + + CommonIntroController get introController; + + Timer? _timer; + + // @mustCallSuper + // void tripleListener(AnimationStatus status) { + // if (status == AnimationStatus.completed) { + // tripleAnimCtr.reset(); + // onTriple(); + // } + // } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + @override + void dispose() { + _cancelTimer(); + tripleAnimCtr.dispose(); + super.dispose(); + } + + void onStartTriple() { + _timer ??= Timer(const Duration(milliseconds: 200), () { + if (introController.hasTriple) { + HapticFeedback.lightImpact(); + SmartDialog.showToast('已完成三连'); + } else { + tripleAnimCtr.forward().whenComplete(() { + tripleAnimCtr.reset(); + introController.actionTriple(); + }); + } + _cancelTimer(); + }); + } + + void onCancelTriple([bool isTapUp = false]) { + if (tripleAnimCtr.status == AnimationStatus.forward) { + tripleAnimCtr.reverse(); + } else if (_timer != null && _timer!.tick == 0) { + _cancelTimer(); + if (isTapUp) { + feedBack(); + introController.actionLikeVideo(); + } + } + } +} + +class TripleBuilder extends StatefulWidget { + const TripleBuilder({ + super.key, + required this.builder, + required this.introController, + // this.tripleListener, + }); + final CommonIntroController introController; + final Widget Function( + BuildContext context, + Animation tripleAnimation, + void Function() onStartTriple, + void Function([bool]) onCancelTriple, + ) + builder; + // final AnimationStatusListener? tripleListener; + + @override + State createState() => _TripleBuilderState(); +} + +class _TripleBuilderState extends TripleState { + @override + Widget build(BuildContext context) => + widget.builder(context, tripleAnimation, onStartTriple, onCancelTriple); + + @override + late final CommonIntroController introController = widget.introController; + + // @override + // void tripleListener(AnimationStatus status) { + // super.tripleListener(status); + // widget.tripleListener?.call(status); + // } +} diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index d44554b6f..25b8b082a 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -1366,11 +1366,8 @@ class _VideoDetailPageVState extends State key: playerKey, plPlayerController: plPlayerController!, videoDetailController: videoDetailController, - ugcIntroController: videoDetailController.isUgc + introController: videoDetailController.isUgc ? ugcIntroController - : null, - pgcIntroController: videoDetailController.isUgc - ? null : pgcIntroController, headerControl: HeaderControl( key: videoDetailController.headerCtrKey, diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index d20745cf3..2516b317f 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -18,6 +18,7 @@ import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/menu_row.dart'; +import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_state.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_repeat.dart'; import 'package:PiliPlus/plugin/pl_player/utils/fullscreen.dart'; @@ -62,8 +63,7 @@ class HeaderControl extends StatefulWidget { State createState() => HeaderControlState(); } -class HeaderControlState extends State - with SingleTickerProviderStateMixin, TripleAnimMixin { +class HeaderControlState extends State { late final PlPlayerController plPlayerController = widget.controller; late final VideoDetailController videoDetailCtr = widget.videoDetailCtr; late final PlayUrlModel videoInfo = videoDetailCtr.data; @@ -72,8 +72,7 @@ class HeaderControlState extends State String get heroTag => widget.heroTag; late final UgcIntroController ugcIntroController; late final PgcIntroController pgcIntroController; - @override - late final CommonIntroController introController = videoDetailCtr.isUgc + late CommonIntroController introController = videoDetailCtr.isUgc ? ugcIntroController : pgcIntroController; bool get horizontalScreen => videoDetailCtr.horizontalScreen; @@ -86,9 +85,9 @@ class HeaderControlState extends State void initState() { super.initState(); if (videoDetailCtr.isUgc) { - ugcIntroController = Get.find(tag: heroTag); + introController = Get.find(tag: heroTag); } else { - pgcIntroController = Get.find(tag: heroTag); + introController = Get.find(tag: heroTag); } } @@ -938,7 +937,7 @@ class HeaderControlState extends State final sliderTheme = SliderThemeData( trackHeight: 10, - trackShape: MSliderTrackShape(), + trackShape: const MSliderTrackShape(), thumbColor: theme.colorScheme.primary, activeTrackColor: theme.colorScheme.primary, inactiveTrackColor: theme.colorScheme.onInverseSurface, @@ -1227,7 +1226,7 @@ class HeaderControlState extends State ); } - Widget resetBtn(ThemeData theme, def, VoidCallback onPressed) { + Widget resetBtn(ThemeData theme, Object def, VoidCallback onPressed) { return iconButton( context: context, tooltip: '默认值: $def', @@ -1279,7 +1278,7 @@ class HeaderControlState extends State final sliderTheme = SliderThemeData( trackHeight: 10, - trackShape: MSliderTrackShape(), + trackShape: const MSliderTrackShape(), thumbColor: theme.colorScheme.primary, activeTrackColor: theme.colorScheme.primary, inactiveTrackColor: theme.colorScheme.onInverseSurface, @@ -2218,11 +2217,43 @@ class HeaderControlState extends State ), ], ), - if (showFSActionItem) - isFullScreen - ? Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ + if (showFSActionItem && isFullScreen) + TripleBuilder( + introController: introController, + builder: (context, tripleAnimation, onStartTriple, onCancelTriple) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 42, + height: 34, + child: Obx( + () => ActionItem( + expand: false, + icon: const Icon( + FontAwesomeIcons.thumbsUp, + color: Colors.white, + ), + selectIcon: const Icon( + FontAwesomeIcons.solidThumbsUp, + ), + selectStatus: introController.hasLike.value, + semanticsLabel: '点赞', + animation: tripleAnimation, + onStartTriple: () { + plPlayerController.tripling = true; + onStartTriple(); + }, + onCancelTriple: ([bool isTap = false]) { + plPlayerController + ..tripling = false + ..hideTaskControls(); + onCancelTriple(isTap); + }, + ), + ), + ), + if (introController case UgcIntroController ugc) SizedBox( width: 42, height: 34, @@ -2230,112 +2261,76 @@ class HeaderControlState extends State () => ActionItem( expand: false, icon: const Icon( - FontAwesomeIcons.thumbsUp, + FontAwesomeIcons.thumbsDown, color: Colors.white, ), selectIcon: const Icon( - FontAwesomeIcons.solidThumbsUp, + FontAwesomeIcons.solidThumbsDown, ), - onTap: () => - handleAction(introController.actionLikeVideo), - selectStatus: introController.hasLike.value, - semanticsLabel: '点赞', - controller: animController, - animation: animation, - onStartTriple: () { - plPlayerController.isTriple = true; - onStartTriple(); - }, - onCancelTriple: (value) { - plPlayerController - ..isTriple = null - ..hideTaskControls(); - onCancelTriple(value); - }, + onTap: ugc.actionDislikeVideo, + selectStatus: ugc.hasDislike.value, + semanticsLabel: '点踩', ), ), ), - if (videoDetailCtr.isUgc) - SizedBox( - width: 42, - height: 34, - child: Obx( - () => ActionItem( - expand: false, - icon: const Icon( - FontAwesomeIcons.thumbsDown, - color: Colors.white, - ), - selectIcon: const Icon( - FontAwesomeIcons.solidThumbsDown, - ), - onTap: () => handleAction( - ugcIntroController.actionDislikeVideo, - ), - selectStatus: ugcIntroController.hasDislike.value, - semanticsLabel: '点踩', - ), - ), - ), - SizedBox( - width: 42, - height: 34, - child: Obx( - () => ActionItem( - expand: false, - icon: const Icon( - FontAwesomeIcons.b, - color: Colors.white, - ), - selectIcon: const Icon(FontAwesomeIcons.b), - onTap: introController.actionCoinVideo, - selectStatus: introController.hasCoin, - semanticsLabel: '投币', - controller: animController, - animation: animation, - ), - ), - ), - SizedBox( - width: 42, - height: 34, - child: Obx( - () => ActionItem( - expand: false, - icon: const Icon( - FontAwesomeIcons.star, - color: Colors.white, - ), - selectIcon: const Icon(FontAwesomeIcons.solidStar), - onTap: () => - introController.showFavBottomSheet(context), - onLongPress: () => introController.showFavBottomSheet( - context, - isLongPress: true, - ), - selectStatus: introController.hasFav.value, - semanticsLabel: '收藏', - controller: animController, - animation: animation, - ), - ), - ), - SizedBox( - width: 42, - height: 34, - child: ActionItem( + SizedBox( + width: 42, + height: 34, + child: Obx( + () => ActionItem( expand: false, + animation: tripleAnimation, icon: const Icon( - FontAwesomeIcons.shareFromSquare, + FontAwesomeIcons.b, color: Colors.white, ), - onTap: () => introController.actionShareVideo(context), - semanticsLabel: '分享', + selectIcon: const Icon(FontAwesomeIcons.b), + onTap: introController.actionCoinVideo, + selectStatus: introController.hasCoin, + semanticsLabel: '投币', ), ), - ], - ) - : const SizedBox.shrink(), + ), + SizedBox( + width: 42, + height: 34, + child: Obx( + () => ActionItem( + expand: false, + animation: tripleAnimation, + icon: const Icon( + FontAwesomeIcons.star, + color: Colors.white, + ), + selectIcon: const Icon(FontAwesomeIcons.solidStar), + onTap: () => + introController.showFavBottomSheet(context), + onLongPress: () => introController.showFavBottomSheet( + context, + isLongPress: true, + ), + selectStatus: introController.hasFav.value, + semanticsLabel: '收藏', + ), + ), + ), + SizedBox( + width: 42, + height: 34, + child: ActionItem( + expand: false, + icon: const Icon( + FontAwesomeIcons.shareFromSquare, + color: Colors.white, + ), + onTap: () => introController.actionShareVideo(context), + semanticsLabel: '分享', + ), + ), + ], + ); + }, + ), ], ), ); @@ -2349,6 +2344,8 @@ class HeaderControlState extends State } class MSliderTrackShape extends RoundedRectSliderTrackShape { + const MSliderTrackShape(); + @override Rect getPreferredRect({ required RenderBox parentBox, diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index 83ad27537..fdaa7f48d 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -1160,7 +1160,7 @@ class PlPlayerController { } } - bool? isTriple; + bool tripling = false; /// 隐藏控制条 void hideTaskControls() { @@ -1169,7 +1169,7 @@ class PlPlayerController { } Duration waitingTime = Duration(seconds: enableLongShowControl ? 30 : 3); _timer = Timer(waitingTime, () { - if (!isSliderMoving.value && isTriple != true) { + if (!isSliderMoving.value && !tripling) { controls = false; } _timer = null; diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 0140dfb22..87af4111c 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -12,7 +12,6 @@ import 'package:PiliPlus/models_new/video/video_shot/data.dart'; import 'package:PiliPlus/pages/common/common_intro_controller.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart'; -import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/bottom_control_type.dart'; import 'package:PiliPlus/plugin/pl_player/models/bottom_progress_behavior.dart'; @@ -49,8 +48,7 @@ class PLVideoPlayer extends StatefulWidget { const PLVideoPlayer({ required this.plPlayerController, this.videoDetailController, - this.ugcIntroController, - this.pgcIntroController, + this.introController, required this.headerControl, this.bottomControl, this.danmuWidget, @@ -65,8 +63,7 @@ class PLVideoPlayer extends StatefulWidget { final PlPlayerController plPlayerController; final VideoDetailController? videoDetailController; - final UgcIntroController? ugcIntroController; - final PgcIntroController? pgcIntroController; + final CommonIntroController? introController; final Widget headerControl; final Widget? bottomControl; final Widget? danmuWidget; @@ -87,12 +84,7 @@ class _PLVideoPlayerState extends State with TickerProviderStateMixin { late AnimationController animationController; late VideoController videoController; - UgcIntroController? ugcIntroController; - PgcIntroController? pgcIntroController; - late final CommonIntroController introController = - widget.videoDetailController!.isUgc - ? ugcIntroController! - : pgcIntroController!; + late final CommonIntroController introController = widget.introController!; final GlobalKey _playerKey = GlobalKey(); final GlobalKey key = GlobalKey(); @@ -175,8 +167,6 @@ class _PLVideoPlayerState extends State duration: const Duration(milliseconds: 100), ); videoController = plPlayerController.videoController!; - ugcIntroController = widget.ugcIntroController; - pgcIntroController = widget.pgcIntroController; Future.microtask(() async { try { FlutterVolumeController.updateShowSystemUI(true); @@ -258,15 +248,18 @@ class _PLVideoPlayerState extends State final videoDetail = introController.videoDetail.value; final isSeason = videoDetail.ugcSeason != null; final isPart = videoDetail.pages != null && videoDetail.pages!.length > 1; - final isPgc = pgcIntroController != null; + final isPgc = !widget.videoDetailController!.isUgc; final anySeason = isSeason || isPart || isPgc; - final isPlayAll = widget.videoDetailController?.isPlayAll == true; + final isPlayAll = + anySeason || widget.videoDetailController?.isPlayAll == true; final double widgetWidth = isFullScreen && context.isLandscape ? 42 : 35; - Map videoProgressWidgets = { + Widget progressWidget( + BottomControlType bottomControl, + ) => switch (bottomControl) { /// 上一集 - BottomControlType.pre: Container( + BottomControlType.pre => Container( width: widgetWidth, height: 30, alignment: Alignment.center, @@ -286,12 +279,12 @@ class _PLVideoPlayerState extends State ), /// 播放暂停 - BottomControlType.playOrPause: PlayOrPauseButton( + BottomControlType.playOrPause => PlayOrPauseButton( plPlayerController: plPlayerController, ), /// 下一集 - BottomControlType.next: Container( + BottomControlType.next => Container( width: widgetWidth, height: 30, alignment: Alignment.center, @@ -311,7 +304,7 @@ class _PLVideoPlayerState extends State ), /// 时间进度 - BottomControlType.time: Column( + BottomControlType.time => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -346,7 +339,7 @@ class _PLVideoPlayerState extends State ), /// 高能进度条 - BottomControlType.dmChart: Obx( + BottomControlType.dmChart => Obx( () => plPlayerController.dmTrend.isEmpty ? const SizedBox.shrink() : Container( @@ -383,46 +376,47 @@ class _PLVideoPlayerState extends State ), /// 超分辨率 - BottomControlType.superResolution: plPlayerController.isAnim - ? Container( - height: 30, - margin: const EdgeInsets.symmetric(horizontal: 10), - alignment: Alignment.center, - child: PopupMenuButton( - initialValue: SuperResolutionType - .values[plPlayerController.superResolutionType], - color: Colors.black.withValues(alpha: 0.8), - itemBuilder: (BuildContext context) { - return SuperResolutionType.values.map(( - SuperResolutionType type, - ) { - return PopupMenuItem( - height: 35, - padding: const EdgeInsets.only(left: 30), - value: type, - onTap: () => plPlayerController.setShader(type.index), - child: Text( - type.title, - style: const TextStyle( - color: Colors.white, - fontSize: 13, + BottomControlType.superResolution => + plPlayerController.isAnim + ? Container( + height: 30, + margin: const EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.center, + child: PopupMenuButton( + initialValue: SuperResolutionType + .values[plPlayerController.superResolutionType], + color: Colors.black.withValues(alpha: 0.8), + itemBuilder: (BuildContext context) { + return SuperResolutionType.values.map(( + SuperResolutionType type, + ) { + return PopupMenuItem( + height: 35, + padding: const EdgeInsets.only(left: 30), + value: type, + onTap: () => plPlayerController.setShader(type.index), + child: Text( + type.title, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + ), ), - ), - ); - }).toList(); - }, - child: Text( - SuperResolutionType - .values[plPlayerController.superResolutionType] - .title, - style: const TextStyle(color: Colors.white, fontSize: 13), + ); + }).toList(); + }, + child: Text( + SuperResolutionType + .values[plPlayerController.superResolutionType] + .title, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), ), - ), - ) - : const SizedBox.shrink(), + ) + : const SizedBox.shrink(), /// 分段信息 - BottomControlType.viewPoints: Obx( + BottomControlType.viewPoints => Obx( () => plPlayerController.viewPointList.isEmpty ? const SizedBox.shrink() : Container( @@ -450,7 +444,7 @@ class _PLVideoPlayerState extends State ), /// 选集 - BottomControlType.episode: Container( + BottomControlType.episode => Container( width: widgetWidth, height: 30, alignment: Alignment.center, @@ -487,7 +481,8 @@ class _PLVideoPlayerState extends State } else if (isPart) { episodes = videoDetail.pages!; } else if (isPgc) { - episodes = pgcIntroController!.pgcItem.episodes!; + episodes = + (introController as PgcIntroController).pgcItem.episodes!; } widget.showEpisodes?.call( index, @@ -504,7 +499,7 @@ class _PLVideoPlayerState extends State ), /// 画面比例 - BottomControlType.fit: Container( + BottomControlType.fit => Container( height: 30, margin: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, @@ -533,7 +528,7 @@ class _PLVideoPlayerState extends State ), /// 字幕 - BottomControlType.subtitle: Obx( + BottomControlType.subtitle => Obx( () => widget.videoDetailController?.subtitles.isEmpty == true ? const SizedBox.shrink() : SizedBox( @@ -590,7 +585,7 @@ class _PLVideoPlayerState extends State ), /// 播放速度 - BottomControlType.speed: Obx( + BottomControlType.speed => Obx( () => Container( height: 30, margin: const EdgeInsets.symmetric(horizontal: 10), @@ -623,7 +618,7 @@ class _PLVideoPlayerState extends State ), /// 全屏 - BottomControlType.fullscreen: SizedBox( + BottomControlType.fullscreen => SizedBox( width: widgetWidth, height: 30, child: Obx( @@ -644,7 +639,7 @@ class _PLVideoPlayerState extends State List userSpecifyItemLeft = [ BottomControlType.playOrPause, BottomControlType.time, - if (anySeason || isPlayAll) ...[ + if (isPlayAll) ...[ BottomControlType.pre, BottomControlType.next, ], @@ -654,7 +649,7 @@ class _PLVideoPlayerState extends State BottomControlType.dmChart, BottomControlType.superResolution, BottomControlType.viewPoints, - if (anySeason || isPlayAll) BottomControlType.episode, + if (isPlayAll) BottomControlType.episode, if (isFullScreen) BottomControlType.fit, BottomControlType.subtitle, BottomControlType.speed, @@ -663,7 +658,7 @@ class _PLVideoPlayerState extends State return Row( children: [ - ...userSpecifyItemLeft.map((item) => videoProgressWidgets[item]!), + ...userSpecifyItemLeft.map(progressWidget), Expanded( child: LayoutBuilder( builder: (context, constraints) => FittedBox( @@ -673,9 +668,7 @@ class _PLVideoPlayerState extends State ), child: Row( mainAxisAlignment: MainAxisAlignment.end, - children: userSpecifyItemRight - .map((item) => videoProgressWidgets[item]!) - .toList(), + children: userSpecifyItemRight.map(progressWidget).toList(), ), ), ), @@ -810,7 +803,7 @@ class _PLVideoPlayerState extends State curSliderPosition + (plPlayerController.sliderScale * delta.dx / maxWidth) .round(), - ); // TODO + ); final Duration result = pos.clamp( Duration.zero, plPlayerController.duration.value,