From 4140c4eb5099db49abbfcdb1716d9bd76677da0a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:25:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E5=8F=8C=E5=87=BB=E7=82=B9=E8=B5=9E=E4=B8=8E=E9=A3=98?= =?UTF-8?q?=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 2 + lib/pages/player/player_view.dart | 3 + lib/pages/player/player_view_actions.dart | 23 +++ .../player/widgets/home_feed_overlay.dart | 41 ++++++ .../widgets/player_double_tap_hearts.dart | 132 ++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 lib/pages/player/widgets/player_double_tap_hearts.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d7c8081..88688bf 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1294,3 +1294,9 @@ $ flutter test - 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。 - 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。 - 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。 + +## 2026-10-22 播放器双击点赞与飘心 + +- 沉浸式播放手势增加原生双击识别,双击优先于单击暂停,长按左右倍速与中间更多保持不变。 +- 双击仅对未点赞剧集调用现有点赞接口,已点赞或请求中只播动画;触发轻震动并在失败时回滚提示。 +- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index cbc1920..56ab3f8 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -60,6 +60,8 @@ abstract final class AppColors { /// 收藏与喜欢(非主色铺色,独立强调)。 static const favorite = Color(0xFFFF6A8A); + static const doubleTapHeartRed = Color(0xFFFF4D6D); + static const doubleTapHeartPink = Color(0xFFFF7A9A); /// 金币底色上的文字。 static const onGold = Color(0xFF3D2408); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3c2b3ef..7379f51 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -24,6 +24,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/centered_toast.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; @@ -655,6 +656,8 @@ class _PlayerPageState extends State } } }, + onDoubleTapLike: (_) => + unawaited(_likeFromDoubleTap(context, interactions, episode)), onSave: () => unawaited(_toggleSave(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 448b7c2..6f58164 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -96,6 +96,29 @@ extension _PlayerViewActions on _PlayerPageState { ); } + Future _likeFromDoubleTap( + BuildContext context, + InteractionController interactions, + Episode episode, + ) async { + unawaited(context.read().lightImpact()); + if (interactions.isEpisodeLiked(episode.id) || + interactions.isEpisodeBusy(episode.id)) { + return; + } + if (!await ensureSignedIn(context) || !context.mounted) return; + if (interactions.isEpisodeLiked(episode.id) || + interactions.isEpisodeBusy(episode.id)) { + return; + } + final result = await interactions.toggleEpisodeLike(episode.id); + if (result.isSuccess || !context.mounted) return; + showCenteredToast( + context, + result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, + ); + } + Future _toggleSave(BuildContext context, Drama drama) async { final favorites = context.read(); if (!await ensureSignedIn(context) || !context.mounted) return; diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index f3ed2e1..88295de 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -14,6 +14,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; +import 'player_double_tap_hearts.dart'; import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; @@ -54,6 +55,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onBoostStart, required this.onBoostEnd, required this.onMore, + this.onDoubleTapLike, this.likeCount, this.favoriteCount, this.commentCount, @@ -91,6 +93,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onBoostStart; final VoidCallback onBoostEnd; final VoidCallback onMore; + final ValueChanged? onDoubleTapLike; final VoidCallback? onScrubStart; final ValueChanged? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; @@ -106,6 +109,9 @@ class HomeFeedOverlay extends StatefulWidget { class _HomeFeedOverlayState extends State { bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; + Offset? _doubleTapPosition; + int _nextHeartBurstId = 0; + final List<({int id, Offset position})> _heartBursts = []; @override void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { @@ -130,6 +136,9 @@ class _HomeFeedOverlayState extends State { key: const Key('home-feed-gesture'), behavior: HitTestBehavior.opaque, onTap: _handleSurfaceTap, + onDoubleTapDown: (details) => + _doubleTapPosition = details.localPosition, + onDoubleTap: _handleDoubleTap, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => widget.onBoostEnd(), @@ -193,6 +202,25 @@ class _HomeFeedOverlayState extends State { onSeek: widget.onSeek, ), ), + if (_heartBursts.isNotEmpty) + Positioned.fill( + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final burst in _heartBursts) + Positioned( + left: burst.position.dx, + top: burst.position.dy - 10, + child: PlayerDoubleTapHeartBurst( + key: ValueKey(burst.id), + onCompleted: () => _removeHeartBurst(burst.id), + ), + ), + ], + ), + ), + ), ], ), ); @@ -218,6 +246,19 @@ class _HomeFeedOverlayState extends State { widget.onTogglePlay(); } + void _handleDoubleTap() { + final position = _doubleTapPosition; + if (position == null) return; + final id = _nextHeartBurstId++; + setState(() => _heartBursts.add((id: id, position: position))); + widget.onDoubleTapLike?.call(position); + } + + void _removeHeartBurst(int id) { + if (!mounted) return; + setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + } + void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; final dx = details.localPosition.dx; diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart new file mode 100644 index 0000000..6fc0e9c --- /dev/null +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -0,0 +1,132 @@ +// Author: fruit.com.cn + +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +class PlayerDoubleTapHeartBurst extends StatefulWidget { + const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + + final VoidCallback onCompleted; + + @override + State createState() => + _PlayerDoubleTapHeartBurstState(); +} + +class _PlayerDoubleTapHeartBurstState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final List<_HeartParticle> _particles; + bool _completed = false; + + @override + void initState() { + super.initState(); + final random = Random(); + var delay = 0; + _particles = List.generate(6 + random.nextInt(3), (index) { + if (index > 0) delay += 40 + random.nextInt(21); + return _HeartParticle( + delay: delay, + duration: 700 + random.nextInt(201), + travel: 120 + random.nextInt(41), + drift: -30 + random.nextInt(61), + size: (22 + random.nextInt(13)).toDouble(), + color: random.nextBool() + ? AppColors.doubleTapHeartRed + : AppColors.doubleTapHeartPink, + ); + }); + final total = _particles + .map((particle) => particle.delay + particle.duration) + .reduce(max); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: total), + )..addStatusListener(_onStatus); + _controller.forward(); + } + + void _onStatus(AnimationStatus status) { + if (status != AnimationStatus.completed || _completed) return; + _completed = true; + widget.onCompleted(); + } + + @override + void dispose() { + _controller + ..removeStatusListener(_onStatus) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: SizedBox.shrink( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final particle in _particles) _particle(particle, elapsed), + ], + ); + }, + ), + ), + ); + } + + Widget _particle(_HeartParticle particle, double elapsed) { + final progress = ((elapsed - particle.delay) / particle.duration).clamp( + 0.0, + 1.0, + ); + if (progress <= 0) return const SizedBox.shrink(); + final move = Curves.easeOutCubic.transform(progress); + final scale = progress < 0.3 + ? 0.55 + (1.15 - 0.55) * (progress / 0.3) + : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); + return Positioned( + left: particle.drift * move - particle.size / 2, + top: -particle.travel * move, + child: Opacity( + opacity: 1 - Curves.easeIn.transform(progress), + child: Transform.scale( + scale: scale, + child: Icon( + Icons.favorite_rounded, + color: particle.color, + size: particle.size, + ), + ), + ), + ); + } +} + +class _HeartParticle { + const _HeartParticle({ + required this.delay, + required this.duration, + required this.travel, + required this.drift, + required this.size, + required this.color, + }); + + final int delay; + final int duration; + final int travel; + final int drift; + final double size; + final Color color; +}