diff --git a/lib/main.dart b/lib/main.dart index ff686eb..a4ee1e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { const Positioned.fill( child: VapPlusSvgaPlayer(tag: "room_gift"), ), + Positioned.fill( + child: RoomGiftSeatFlightOverlay( + controller: RoomGiftSeatFlightController(), + resolveTargetKey: + (userId) => Provider.of( + context, + listen: false, + ).getSeatGlobalKeyByIndex(userId), + ), + ), ], ); }, diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 279924a..1d2771d 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -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 final List _pages = [AllChatPage(), ChatPage(), GiftChatPage()]; final List _tabs = []; late StreamSubscription _subscription; + final RoomGiftSeatFlightController _giftSeatFlightController = + RoomGiftSeatFlightController(); @override void initState() { @@ -60,6 +64,7 @@ class _VoiceRoomPageState extends State context, listen: false, ).toggleGiftAnimationVisibility(false); + _giftSeatFlightController.clear(); } }); } @@ -70,6 +75,7 @@ class _VoiceRoomPageState extends State if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) { rtmProvider.msgFloatingGiftListener = null; } + _giftSeatFlightController.clear(); _tabController.dispose(); // 释放资源 _subscription.cancel(); super.dispose(); @@ -272,5 +278,80 @@ class _VoiceRoomPageState extends State 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(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; } } diff --git a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart new file mode 100644 index 0000000..55cb99d --- /dev/null +++ b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart @@ -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>? 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 _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 createState() => + _RoomGiftSeatFlightOverlayState(); +} + +class _RoomGiftSeatFlightOverlayState extends State + with SingleTickerProviderStateMixin { + final Queue<_QueuedRoomGiftSeatFlightRequest> _queue = Queue(); + final GlobalKey _overlayKey = GlobalKey(); + + late final AnimationController _controller; + + RoomGiftSeatFlightRequest? _activeRequest; + ImageProvider? _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 _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 _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 = [0, 0.16, 0.32]; + const trailOpacities = [1, 0.5, 0.24]; + const trailScales = [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, + ); + } +} diff --git a/需求进度.md b/需求进度.md index 035815c..fca28ae 100644 --- a/需求进度.md +++ b/需求进度.md @@ -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 的方式提供与普通礼物一致的即时播报体验。