静态礼物动画补充

This commit is contained in:
NIGGER SLAYER 2026-04-17 12:00:18 +08:00
parent afc6401e68
commit ea99051267
4 changed files with 528 additions and 0 deletions

View File

@ -42,6 +42,7 @@ import 'services/shop/shop_manager.dart';
import 'services/theme/theme_manager.dart';
import 'services/auth/user_profile_manager.dart';
import 'ui_kit/theme/socialchat_theme.dart';
import 'ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart';
import 'ui_kit/widgets/room/effect/vapp_svga_layer_widget.dart';
bool _isCrashlyticsReady = false;
@ -410,6 +411,16 @@ class _YumiApplicationState extends State<YumiApplication> {
const Positioned.fill(
child: VapPlusSvgaPlayer(tag: "room_gift"),
),
Positioned.fill(
child: RoomGiftSeatFlightOverlay(
controller: RoomGiftSeatFlightController(),
resolveTargetKey:
(userId) => Provider.of<RtcProvider>(
context,
listen: false,
).getSeatGlobalKeyByIndex(userId),
),
),
],
);
},

View File

@ -13,7 +13,9 @@ import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart';
import 'package:yumi/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart';
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_screen.dart';
import 'package:yumi/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
@ -42,6 +44,8 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
final List<Widget> _pages = [AllChatPage(), ChatPage(), GiftChatPage()];
final List<Widget> _tabs = [];
late StreamSubscription _subscription;
final RoomGiftSeatFlightController _giftSeatFlightController =
RoomGiftSeatFlightController();
@override
void initState() {
@ -60,6 +64,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
context,
listen: false,
).toggleGiftAnimationVisibility(false);
_giftSeatFlightController.clear();
}
});
}
@ -70,6 +75,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) {
rtmProvider.msgFloatingGiftListener = null;
}
_giftSeatFlightController.clear();
_tabController.dispose(); //
_subscription.cancel();
super.dispose();
@ -272,5 +278,80 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
context,
listen: false,
).enqueueGiftAnimation(giftModel);
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
final targetUserId = _resolveGiftTargetUserId(msg);
if (_shouldPlaySeatFlightGiftAnimation(msg) && targetUserId != null) {
_giftSeatFlightController.enqueue(
RoomGiftSeatFlightRequest(
imagePath: giftPhoto,
targetUserId: targetUserId,
beginSize: 96.w,
endSize: 28.w,
),
);
}
}
bool _shouldPlaySeatFlightGiftAnimation(Msg msg) {
final gift = msg.gift;
if (gift == null) {
return false;
}
final giftPhoto = (gift.giftPhoto ?? "").trim();
if (giftPhoto.isEmpty) {
return false;
}
final giftPhotoExt = _normalizedGiftResourceExtension(giftPhoto);
if (_isAnimatedGiftResource(giftPhotoExt)) {
return false;
}
final giftSourceUrl = (gift.giftSourceUrl ?? "").trim();
final sourceExt = _normalizedGiftResourceExtension(giftSourceUrl);
return !_isAnimatedGiftResource(sourceExt);
}
bool _isAnimatedGiftResource(String extension) {
return extension == ".svga" || extension == ".mp4" || extension == ".vap";
}
String _normalizedGiftResourceExtension(String resource) {
final value = resource.trim();
if (value.isEmpty) {
return "";
}
final uri = Uri.tryParse(value);
if (uri != null && ((uri.scheme.isNotEmpty) || (uri.host.isNotEmpty))) {
return SCPathUtils.getFileExtension(uri.path).toLowerCase();
}
final normalizedValue = value.split("?").first.split("#").first;
return SCPathUtils.getFileExtension(normalizedValue).toLowerCase();
}
String? _resolveGiftTargetUserId(Msg msg) {
final directUserId = (msg.toUser?.id ?? "").trim();
if (directUserId.isNotEmpty) {
return directUserId;
}
final targetAccount = (msg.toUser?.account ?? "").trim();
if (targetAccount.isEmpty) {
return null;
}
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
for (final micRes in rtcProvider.roomWheatMap.values) {
if ((micRes.user?.account ?? "").trim() == targetAccount) {
final userId = (micRes.user?.id ?? "").trim();
if (userId.isNotEmpty) {
return userId;
}
}
}
return null;
}
}

View File

@ -0,0 +1,433 @@
import 'dart:collection';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
typedef RoomGiftSeatTargetResolver =
GlobalKey<State<StatefulWidget>>? Function(String targetUserId);
class RoomGiftSeatFlightRequest {
const RoomGiftSeatFlightRequest({
required this.imagePath,
required this.targetUserId,
this.holdDuration = const Duration(milliseconds: 780),
this.flightDuration = const Duration(milliseconds: 920),
this.beginSize = 96,
this.endSize = 34,
});
final String imagePath;
final String targetUserId;
final Duration holdDuration;
final Duration flightDuration;
final double beginSize;
final double endSize;
}
class RoomGiftSeatFlightController {
static final RoomGiftSeatFlightController _instance =
RoomGiftSeatFlightController._internal();
factory RoomGiftSeatFlightController() => _instance;
RoomGiftSeatFlightController._internal();
final Queue<RoomGiftSeatFlightRequest> _pendingRequests = Queue();
_RoomGiftSeatFlightOverlayState? _state;
void enqueue(RoomGiftSeatFlightRequest request) {
final imagePath = request.imagePath.trim();
final targetUserId = request.targetUserId.trim();
if (imagePath.isEmpty || targetUserId.isEmpty) {
return;
}
final normalizedRequest = RoomGiftSeatFlightRequest(
imagePath: imagePath,
targetUserId: targetUserId,
holdDuration: request.holdDuration,
flightDuration: request.flightDuration,
beginSize: request.beginSize,
endSize: request.endSize,
);
if (_state == null) {
_pendingRequests.add(normalizedRequest);
return;
}
_state!._enqueue(normalizedRequest);
}
void clear() {
_pendingRequests.clear();
_state?._clear();
}
void _attach(_RoomGiftSeatFlightOverlayState state) {
_state = state;
while (_pendingRequests.isNotEmpty) {
state._enqueue(_pendingRequests.removeFirst());
}
}
void _detach(_RoomGiftSeatFlightOverlayState state) {
if (_state == state) {
_state = null;
}
}
}
class RoomGiftSeatFlightOverlay extends StatefulWidget {
const RoomGiftSeatFlightOverlay({
super.key,
required this.controller,
required this.resolveTargetKey,
});
final RoomGiftSeatFlightController controller;
final RoomGiftSeatTargetResolver resolveTargetKey;
@override
State<RoomGiftSeatFlightOverlay> createState() =>
_RoomGiftSeatFlightOverlayState();
}
class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
with SingleTickerProviderStateMixin {
final Queue<_QueuedRoomGiftSeatFlightRequest> _queue = Queue();
final GlobalKey _overlayKey = GlobalKey();
late final AnimationController _controller;
RoomGiftSeatFlightRequest? _activeRequest;
ImageProvider<Object>? _activeImageProvider;
Offset? _activeTargetOffset;
bool _isPlaying = false;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_finishCurrentAnimation();
}
});
widget.controller._attach(this);
}
@override
void didUpdateWidget(covariant RoomGiftSeatFlightOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller._detach(this);
widget.controller._attach(this);
}
}
@override
void dispose() {
widget.controller._detach(this);
_controller.dispose();
super.dispose();
}
void _enqueue(RoomGiftSeatFlightRequest request) {
_queue.add(_QueuedRoomGiftSeatFlightRequest(request: request));
_scheduleNextAnimation();
}
void _clear() {
_queue.clear();
_controller.stop();
_controller.reset();
if (!mounted) {
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
return;
}
setState(() {
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
});
}
void _scheduleNextAnimation() {
if (_isPlaying || _queue.isEmpty || !mounted) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_startNextAnimation();
});
}
Future<void> _startNextAnimation() async {
if (!mounted || _isPlaying || _queue.isEmpty) {
return;
}
final queuedRequest = _queue.removeFirst();
final targetOffset = _resolveTargetOffset(
queuedRequest.request.targetUserId,
);
if (targetOffset == null) {
if (queuedRequest.retryCount < 6) {
Future.delayed(const Duration(milliseconds: 120), () {
if (!mounted) {
return;
}
_queue.addFirst(
queuedRequest.copyWith(retryCount: queuedRequest.retryCount + 1),
);
_scheduleNextAnimation();
});
} else {
_scheduleNextAnimation();
}
return;
}
final imageProvider = _resolveImageProvider(
queuedRequest.request.imagePath,
);
_isPlaying = true;
_controller.duration =
queuedRequest.request.holdDuration +
queuedRequest.request.flightDuration;
setState(() {
_activeRequest = queuedRequest.request;
_activeTargetOffset = targetOffset;
_activeImageProvider = imageProvider;
});
try {
await precacheImage(
imageProvider,
context,
).timeout(const Duration(milliseconds: 250));
} catch (_) {}
if (!mounted || _activeRequest != queuedRequest.request) {
return;
}
_controller.reset();
_controller.forward();
}
void _finishCurrentAnimation() {
if (!mounted) {
return;
}
setState(() {
_activeRequest = null;
_activeImageProvider = null;
_activeTargetOffset = null;
_isPlaying = false;
});
_scheduleNextAnimation();
}
Offset? _resolveTargetOffset(String targetUserId) {
final overlayContext = _overlayKey.currentContext;
final targetKey = widget.resolveTargetKey(targetUserId);
final targetContext = targetKey?.currentContext;
if (overlayContext == null || targetContext == null) {
return null;
}
final overlayBox = overlayContext.findRenderObject();
final targetBox = targetContext.findRenderObject();
if (overlayBox is! RenderBox ||
targetBox is! RenderBox ||
!overlayBox.hasSize ||
!targetBox.hasSize) {
return null;
}
final globalTargetCenter = targetBox.localToGlobal(
targetBox.size.center(Offset.zero),
);
return overlayBox.globalToLocal(globalTargetCenter);
}
ImageProvider<Object> _resolveImageProvider(String imagePath) {
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return buildCachedImageProvider(imagePath);
}
if (imagePath.startsWith('/')) {
return FileImage(File(imagePath));
}
return AssetImage(imagePath);
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: SizedBox.expand(
key: _overlayKey,
child:
_activeRequest == null ||
_activeImageProvider == null ||
_activeTargetOffset == null
? const SizedBox.shrink()
: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final renderBox =
_overlayKey.currentContext?.findRenderObject()
as RenderBox?;
final overlaySize =
renderBox?.size ?? MediaQuery.sizeOf(context);
final center = overlaySize.center(Offset.zero);
final request = _activeRequest!;
final totalDuration =
request.holdDuration + request.flightDuration;
final holdRatio =
totalDuration.inMilliseconds == 0
? 0.0
: request.holdDuration.inMilliseconds /
totalDuration.inMilliseconds;
final progress = _controller.value;
if (progress <= holdRatio) {
final normalizedHold =
holdRatio == 0
? 1.0
: (progress / holdRatio).clamp(0.0, 1.0);
final pulseScale =
1 + math.sin(normalizedHold * math.pi) * 0.05;
return Stack(
clipBehavior: Clip.none,
children: [
_buildGiftNode(
center: center,
size: request.beginSize,
opacity: 1,
scale: pulseScale,
),
],
);
}
final flightProgress = ((progress - holdRatio) /
(1 - holdRatio))
.clamp(0.0, 1.0);
final travelProgress = Curves.easeInOutCubic.transform(
flightProgress,
);
const trailGaps = <double>[0, 0.16, 0.32];
const trailOpacities = <double>[1, 0.5, 0.24];
const trailScales = <double>[1, 0.92, 0.84];
return Stack(
clipBehavior: Clip.none,
children:
List.generate(trailGaps.length, (index) {
final trailingProgress = (travelProgress -
trailGaps[index])
.clamp(0.0, 1.0);
final nodeCenter =
Offset.lerp(
center,
_activeTargetOffset,
trailingProgress,
)!;
final nodeSize =
lerpDouble(
request.beginSize,
request.endSize,
trailingProgress,
)!;
final fadeOut =
1 -
Curves.easeOut.transform(
((travelProgress - 0.88) / 0.12).clamp(
0.0,
1.0,
),
);
return _buildGiftNode(
center: nodeCenter,
size: nodeSize,
opacity: trailOpacities[index] * fadeOut,
scale: trailScales[index],
);
}).reversed.toList(),
);
},
),
),
);
}
Widget _buildGiftNode({
required Offset center,
required double size,
required double opacity,
double scale = 1,
}) {
if (_activeImageProvider == null || opacity <= 0 || size <= 0) {
return const SizedBox.shrink();
}
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: Transform.scale(
scale: scale,
child: SizedBox(
width: size,
height: size,
child: DecoratedBox(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.22),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Image(
image: _activeImageProvider!,
fit: BoxFit.contain,
filterQuality: FilterQuality.low,
errorBuilder: (context, error, stackTrace) {
return const SizedBox.shrink();
},
),
),
),
),
),
);
}
}
class _QueuedRoomGiftSeatFlightRequest {
const _QueuedRoomGiftSeatFlightRequest({
required this.request,
this.retryCount = 0,
});
final RoomGiftSeatFlightRequest request;
final int retryCount;
_QueuedRoomGiftSeatFlightRequest copyWith({int? retryCount}) {
return _QueuedRoomGiftSeatFlightRequest(
request: request,
retryCount: retryCount ?? this.retryCount,
);
}
}

View File

@ -13,6 +13,9 @@
- 本轮按需求暂未处理网络链路上的启动等待,例如审核态检查或远端启动页配置请求。
## 已完成模块
- 已将语言房送礼链路接入新的“中心停留后飞向目标麦位”组件,但只对无自带特效的静态 PNG 礼物生效:当前带自身 `SVGA/MP4/VAP` 动画或被识别为全屏礼物特效的礼物保持原有播放逻辑不变;只有普通 PNG 礼物会额外触发“屏幕中央停留 -> 三连残影飞向被赠送麦位”的补充动效,避免和自带礼物特效重复叠播。
- 已继续收敛语言房送礼飞行动效的命中条件:上一版对普通礼物的过滤过严,既依赖 `giftPhoto` 必须显式以 `.png` 结尾,也会被部分礼物的 `special` 标记误伤,导致不少实际没有自带动画的礼物被提前跳过;当前已改为只排除真实带 `SVGA/MP4/VAP` 动画源的礼物,普通静态封面礼物即使是带 query 的图片 URL、或不是严格 `.png` 后缀,也会正常触发“中心停留 -> 飞向目标麦位”的补充动画。
- 已将语言房送礼飞行动画从房间页内部 `Stack` 提升到应用根层,挂载方式对齐现有 `SVGA/VAP` 礼物特效层:当前该动画会和 `VapPlusSvgaPlayer` 一样在 `main.dart` 的顶层 builder 中全屏绘制,因此不会再被房间内部聊天区、局部动效或页面层级压住,视觉上更靠前、更容易被用户看到。
- 已修复礼物页幸运礼物发送链路:当前 `gift_page` 会按礼物类型分流,普通礼物继续走 `/gift/batch``LUCKY_GIFT/LUCK/MAGIC` 改走已存在的 `/gift/give/lucky-gift`;幸运/魔法礼物成功后不再本地伪造普通 `GIFT` 消息,而是改发房间内 `LUCK_GIFT_ANIM_OTHER` 动画消息,避免和后端幸运礼物开奖/补发逻辑打架;同时送礼失败已补用户提示,且对 `standardId` 缺失的幸运礼物增加了前置拦截,避免继续点发送却只有日志没有反馈。
- 已继续补齐幸运礼物前端可见反馈:根据真机日志确认 `giveLuckyGift` 和腾讯 IM 房间消息发送都已成功,当前问题收敛为发送端没有本地回显、且 `LUCK_GIFT_ANIM_OTHER` 收到后未真正接入房间礼物动画;现已在发送成功后先本地触发一轮房间礼物上飘反馈,并把收到的 `LUCK_GIFT_ANIM_OTHER` 直接接入现有房间礼物动画 listener确保发送端和房间内其它端都能看到即时反应。排障期间临时加过的发送并发锁已在后续需求确认后撤回不再限制幸运礼物连点。
- 已按最新确认调整幸运礼物点击策略:撤回前一轮为排障临时加上的发送并发锁,恢复幸运礼物可连续点击;当前连点时每次请求仍走 `/gift/give/lucky-gift`,并继续通过本地回显 + `LUCK_GIFT_ANIM_OTHER` 接入现有房间礼物动画 listener 的方式提供与普通礼物一致的即时播报体验。