merge: 增加播放器双击点赞与飘心

This commit is contained in:
Zhou Weidong committed 2026-10-22 22:25:22 +08:00
commit 78d0166eb7
6 files changed
+207

No files matched your search

+6
View File
@@ -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。
+2
View File
@@ -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);
+3
View File
@@ -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<PlayerPage>
}
}
},
onDoubleTapLike: (_) =>
unawaited(_likeFromDoubleTap(context, interactions, episode)),
onSave: () => unawaited(_toggleSave(context, drama)),
onComments: () => unawaited(_openComments(context, drama, episode)),
onShare: () => logic.shareDrama(context, drama),
+23
View File
@@ -96,6 +96,29 @@ extension _PlayerViewActions on _PlayerPageState {
);
}
Future<void> _likeFromDoubleTap(
BuildContext context,
InteractionController interactions,
Episode episode,
) async {
unawaited(context.read<Haptics>().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<void> _toggleSave(BuildContext context, Drama drama) async {
final favorites = context.read<FavoriteController>();
if (!await ensureSignedIn(context) || !context.mounted) return;
@@ -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<Offset>? onDoubleTapLike;
final VoidCallback? onScrubStart;
final ValueChanged<bool>? onScrubEnd;
final ValueChanged<bool>? onScrubVisibilityChanged;
@@ -106,6 +109,9 @@ class HomeFeedOverlay extends StatefulWidget {
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
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<HomeFeedOverlay> {
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<HomeFeedOverlay> {
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<HomeFeedOverlay> {
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;
@@ -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<PlayerDoubleTapHeartBurst> createState() =>
_PlayerDoubleTapHeartBurstState();
}
class _PlayerDoubleTapHeartBurstState extends State<PlayerDoubleTapHeartBurst>
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;
}