feat: 增加播放器双击点赞与飘心
This commit is contained in:
1 parent
e2cc409e5a
commit
4140c4eb50
6 files changed
+207
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user