feat: 增加播放器双击点赞与飘心
This commit is contained in:
1 parent
e2cc409e5a
commit
4140c4eb50
6 files changed
+207
No files matched your search
@@ -1294,3 +1294,9 @@ $ flutter test
|
|||||||
- 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。
|
- 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。
|
||||||
- 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。
|
- 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。
|
||||||
- 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。
|
- 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。
|
||||||
|
|
||||||
|
## 2026-10-22 播放器双击点赞与飘心
|
||||||
|
|
||||||
|
- 沉浸式播放手势增加原生双击识别,双击优先于单击暂停,长按左右倍速与中间更多保持不变。
|
||||||
|
- 双击仅对未点赞剧集调用现有点赞接口,已点赞或请求中只播动画;触发轻震动并在失败时回滚提示。
|
||||||
|
- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。
|
||||||
@@ -60,6 +60,8 @@ abstract final class AppColors {
|
|||||||
|
|
||||||
/// 收藏与喜欢(非主色铺色,独立强调)。
|
/// 收藏与喜欢(非主色铺色,独立强调)。
|
||||||
static const favorite = Color(0xFFFF6A8A);
|
static const favorite = Color(0xFFFF6A8A);
|
||||||
|
static const doubleTapHeartRed = Color(0xFFFF4D6D);
|
||||||
|
static const doubleTapHeartPink = Color(0xFFFF7A9A);
|
||||||
|
|
||||||
/// 金币底色上的文字。
|
/// 金币底色上的文字。
|
||||||
static const onGold = Color(0xFF3D2408);
|
static const onGold = Color(0xFF3D2408);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '../../core/platform/haptics.dart';
|
|||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
|
import '../../core/widgets/centered_toast.dart';
|
||||||
import '../../core/widgets/download_action.dart';
|
import '../../core/widgets/download_action.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../home/home_feed_channel.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)),
|
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||||
onComments: () => unawaited(_openComments(context, drama, episode)),
|
onComments: () => unawaited(_openComments(context, drama, episode)),
|
||||||
onShare: () => logic.shareDrama(context, drama),
|
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 {
|
Future<void> _toggleSave(BuildContext context, Drama drama) async {
|
||||||
final favorites = context.read<FavoriteController>();
|
final favorites = context.read<FavoriteController>();
|
||||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import 'home_feed_recommend_panel.dart';
|
import 'home_feed_recommend_panel.dart';
|
||||||
import 'loading_progress_track.dart';
|
import 'loading_progress_track.dart';
|
||||||
|
import 'player_double_tap_hearts.dart';
|
||||||
import 'seek_progress_bar.dart';
|
import 'seek_progress_bar.dart';
|
||||||
|
|
||||||
part 'home_feed_overlay_parts.dart';
|
part 'home_feed_overlay_parts.dart';
|
||||||
@@ -54,6 +55,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
required this.onBoostStart,
|
required this.onBoostStart,
|
||||||
required this.onBoostEnd,
|
required this.onBoostEnd,
|
||||||
required this.onMore,
|
required this.onMore,
|
||||||
|
this.onDoubleTapLike,
|
||||||
this.likeCount,
|
this.likeCount,
|
||||||
this.favoriteCount,
|
this.favoriteCount,
|
||||||
this.commentCount,
|
this.commentCount,
|
||||||
@@ -91,6 +93,7 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onBoostStart;
|
final VoidCallback onBoostStart;
|
||||||
final VoidCallback onBoostEnd;
|
final VoidCallback onBoostEnd;
|
||||||
final VoidCallback onMore;
|
final VoidCallback onMore;
|
||||||
|
final ValueChanged<Offset>? onDoubleTapLike;
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final ValueChanged<bool>? onScrubEnd;
|
final ValueChanged<bool>? onScrubEnd;
|
||||||
final ValueChanged<bool>? onScrubVisibilityChanged;
|
final ValueChanged<bool>? onScrubVisibilityChanged;
|
||||||
@@ -106,6 +109,9 @@ class HomeFeedOverlay extends StatefulWidget {
|
|||||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||||
bool _scrubbing = false;
|
bool _scrubbing = false;
|
||||||
bool _wasVisibleBeforeScrub = true;
|
bool _wasVisibleBeforeScrub = true;
|
||||||
|
Offset? _doubleTapPosition;
|
||||||
|
int _nextHeartBurstId = 0;
|
||||||
|
final List<({int id, Offset position})> _heartBursts = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant HomeFeedOverlay oldWidget) {
|
void didUpdateWidget(covariant HomeFeedOverlay oldWidget) {
|
||||||
@@ -130,6 +136,9 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
key: const Key('home-feed-gesture'),
|
key: const Key('home-feed-gesture'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: _handleSurfaceTap,
|
onTap: _handleSurfaceTap,
|
||||||
|
onDoubleTapDown: (details) =>
|
||||||
|
_doubleTapPosition = details.localPosition,
|
||||||
|
onDoubleTap: _handleDoubleTap,
|
||||||
onLongPressStart: (details) =>
|
onLongPressStart: (details) =>
|
||||||
_handleLongPressStart(details, constraints.maxWidth),
|
_handleLongPressStart(details, constraints.maxWidth),
|
||||||
onLongPressEnd: (_) => widget.onBoostEnd(),
|
onLongPressEnd: (_) => widget.onBoostEnd(),
|
||||||
@@ -193,6 +202,25 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
onSeek: widget.onSeek,
|
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();
|
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) {
|
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
||||||
final third = width / 3;
|
final third = width / 3;
|
||||||
final dx = details.localPosition.dx;
|
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