From e63a99faaac65aacf3dde76615eb47db816ba9cc Mon Sep 17 00:00:00 2001 From: NIGGER SLAYER Date: Fri, 17 Apr 2026 17:21:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A4=BC=E7=89=A9=E5=8A=A8=E7=94=BB=E4=BB=A5?= =?UTF-8?q?=E5=8F=8AUI=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/gift/gift_page.dart | 37 +- lib/modules/room/voice_room_page.dart | 1058 ++++++++++------- lib/services/audio/rtc_manager.dart | 21 +- lib/services/audio/rtm_manager.dart | 20 +- lib/services/gift/gift_system_manager.dart | 22 +- .../room/anim/room_entrance_screen.dart | 50 +- .../anim/room_gift_seat_flight_overlay.dart | 195 ++- .../widgets/room/room_bottom_widget.dart | 4 +- lib/ui_kit/widgets/room/room_msg_item.dart | 6 + scripts/build_release.py | 11 +- 需求进度.md | 3 +- 11 files changed, 933 insertions(+), 494 deletions(-) diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index a315e52..7057fce 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -137,6 +137,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { List acceptUsers, { required SocialChatGiftRes gift, required int quantity, + int animationCount = 1, }) { final rtmProvider = Provider.of( navigatorKey.currentState!.context, @@ -176,6 +177,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { user: currentUser, toUser: targetUser, number: quantity, + customAnimationCount: animationCount, ), ); } @@ -1142,6 +1144,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { acceptUsers: acceptUsers, gift: selectedGift, quantity: selectedNumber, + clickCount: 1, roomId: roomId, roomAccount: roomAccount, isLuckyGiftRequest: isLuckyGiftRequest, @@ -1185,12 +1188,14 @@ class _GiftPageState extends State with TickerProviderStateMixin { if (existingBatch != null) { existingBatch.quantity += request.quantity; + existingBatch.clickCount += request.clickCount; existingBatch.readyAt = now.add(_comboSendBatchWindow); _giftFxLog( 'aggregate combo send update ' 'batchKey=${existingBatch.batchKey} ' 'giftId=${request.gift.id} ' 'quantity=${existingBatch.quantity} ' + 'clickCount=${existingBatch.clickCount} ' 'acceptUserIds=${request.acceptUserIds.join(",")}', ); } else { @@ -1204,6 +1209,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'batchKey=${batch.batchKey} ' 'giftId=${request.gift.id} ' 'quantity=${batch.quantity} ' + 'clickCount=${batch.clickCount} ' 'acceptUserIds=${request.acceptUserIds.join(",")}', ); } @@ -1294,6 +1300,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'standardId=${request.gift.standardId} ' 'giftCandy=${request.gift.giftCandy} ' 'number=${request.quantity} ' + 'clickCount=${request.clickCount} ' 'acceptCount=${request.acceptUserIds.length} ' 'acceptUserIds=${request.acceptUserIds.join(",")} ' 'roomId=${request.roomId} ' @@ -1310,7 +1317,8 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'giftId=${request.gift.id} ' 'roomId=${request.roomId} ' 'acceptUserIds=${request.acceptUserIds.join(",")} ' - 'quantity=${request.quantity}', + 'quantity=${request.quantity} ' + 'clickCount=${request.clickCount}', ); final result = request.isLuckyGiftRequest @@ -1337,6 +1345,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'giftTab=${request.gift.giftTab} ' 'standardId=${request.gift.standardId} ' 'number=${request.quantity} ' + 'clickCount=${request.clickCount} ' 'acceptUserIds=${request.acceptUserIds.join(",")} ' 'roomId=${request.roomId} ' 'balance=$result ' @@ -1347,17 +1356,20 @@ class _GiftPageState extends State with TickerProviderStateMixin { request.acceptUsers, gift: request.gift, quantity: request.quantity, + animationCount: request.clickCount, ); await sendLuckGiftAnimOtherMsg( request.acceptUsers, gift: request.gift, quantity: request.quantity, + animationCount: request.clickCount, ); } else { sendGiftMsg( request.acceptUsers, gift: request.gift, quantity: request.quantity, + animationCount: request.clickCount, ); } profileManager?.updateBalance(result); @@ -1383,6 +1395,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { List acceptUsers, { required SocialChatGiftRes gift, required int quantity, + int animationCount = 1, }) { ///发送一条IM消息 for (var u in acceptUsers) { @@ -1417,6 +1430,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { user: AccountStorage().getCurrentUser()?.userProfile, toUser: u.user, number: quantity, + customAnimationCount: animationCount, type: SCRoomMsgType.gift, role: Provider.of( @@ -1459,18 +1473,22 @@ class _GiftPageState extends State with TickerProviderStateMixin { } if (gift.giftSourceUrl != null && gift.special != null) { if (scGiftHasFullScreenEffect(gift.special)) { - if (SCGlobalConfig.isGiftSpecialEffects) { + if (SCGlobalConfig.isGiftSpecialEffects && + (rtcProvider?.shouldShowRoomVisualEffects ?? false)) { _giftFxLog( 'local trigger player play ' 'path=${gift.giftSourceUrl} ' 'giftId=${gift.id} ' - 'giftName=${gift.giftName}', + 'giftName=${gift.giftName} ' + 'animationCount=$animationCount', ); SCGiftVapSvgaManager().play(gift.giftSourceUrl!); } else { _giftFxLog( - 'skip local play because isGiftSpecialEffects=false ' - 'giftId=${gift.id}', + 'skip local play because visual effects disabled ' + 'giftId=${gift.id} ' + 'isGiftSpecialEffects=${SCGlobalConfig.isGiftSpecialEffects} ' + 'roomVisible=${rtcProvider?.shouldShowRoomVisualEffects ?? false}', ); } } else { @@ -1584,6 +1602,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { List acceptUsers, { required SocialChatGiftRes gift, required int quantity, + int animationCount = 1, }) async { final targetUserIds = acceptUsers @@ -1598,6 +1617,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { 'giftName=${gift.giftName} ' 'giftPhoto=${gift.giftPhoto} ' 'quantity=$quantity ' + 'animationCount=$animationCount ' 'firstTargetUserId=${firstTargetUser?.id} ' 'groupId=${rtcProvider?.currenRoom?.roomProfile?.roomProfile?.roomAccount} ' 'roomId=${rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id} ' @@ -1613,6 +1633,7 @@ class _GiftPageState extends State with TickerProviderStateMixin { user: AccountStorage().getCurrentUser()?.userProfile, toUser: firstTargetUser, number: quantity, + customAnimationCount: animationCount, type: SCRoomMsgType.luckGiftAnimOther, msg: jsonEncode(acceptUsers.map((u) => u.user?.id).toList()), ), @@ -1701,6 +1722,7 @@ class _GiftSendRequest { required this.acceptUsers, required this.gift, required this.quantity, + required this.clickCount, required this.roomId, required this.roomAccount, required this.isLuckyGiftRequest, @@ -1710,6 +1732,7 @@ class _GiftSendRequest { final List acceptUsers; final SocialChatGiftRes gift; final int quantity; + final int clickCount; final String roomId; final String roomAccount; final bool isLuckyGiftRequest; @@ -1732,6 +1755,7 @@ class _PendingGiftSendBatch { required this.acceptUsers, required this.gift, required this.quantity, + required this.clickCount, required this.roomId, required this.roomAccount, required this.isLuckyGiftRequest, @@ -1748,6 +1772,7 @@ class _PendingGiftSendBatch { acceptUsers: List.from(request.acceptUsers), gift: request.gift, quantity: request.quantity, + clickCount: request.clickCount, roomId: request.roomId, roomAccount: request.roomAccount, isLuckyGiftRequest: request.isLuckyGiftRequest, @@ -1760,6 +1785,7 @@ class _PendingGiftSendBatch { final List acceptUsers; final SocialChatGiftRes gift; int quantity; + int clickCount; final String roomId; final String roomAccount; final bool isLuckyGiftRequest; @@ -1771,6 +1797,7 @@ class _PendingGiftSendBatch { acceptUsers: List.from(acceptUsers), gift: gift, quantity: quantity, + clickCount: clickCount, roomId: roomId, roomAccount: roomAccount, isLuckyGiftRequest: isLuckyGiftRequest, diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 8ef0118..40305e1 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -1,456 +1,602 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/app/constants/sc_global_config.dart'; -import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/room_bottom_widget.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/shared/tools/sc_lk_event_bus.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; -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_gift_vap_svga_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'; -import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; -import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart'; -import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart'; -import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart'; - -import '../../ui_kit/components/sc_float_ichart.dart'; -import '../../ui_kit/widgets/room/seat/room_seat_widget.dart'; -import 'chat/all/all_chat_page.dart'; -import 'chat/chat/chat_page.dart'; -import 'chat/gift/gift_chat_page.dart'; - -///语聊房 -class VoiceRoomPage extends StatefulWidget { - const VoiceRoomPage({super.key}); - - @override - State createState() => _VoiceRoomPageState(); -} - -class _VoiceRoomPageState extends State - with SingleTickerProviderStateMixin { - static const Duration _luckyGiftComboWindow = Duration(seconds: 3); - - late TabController _tabController; - final List _pages = [AllChatPage(), ChatPage(), GiftChatPage()]; - final List _tabs = []; - late StreamSubscription _subscription; - final RoomGiftSeatFlightController _giftSeatFlightController = - RoomGiftSeatFlightController(); - final Map _luckyGiftComboSessions = - {}; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: _pages.length, vsync: this); - Provider.of(context, listen: false).msgFloatingGiftListener = - _floatingGiftListener; - _tabController.addListener(() {}); // 监听切换 - - _subscription = eventBus.on().listen(( - event, - ) { - if (mounted) { - Provider.of(context, listen: false).clearAllGiftData(); - Provider.of( - context, - listen: false, - ).toggleGiftAnimationVisibility(false); - _clearLuckyGiftComboSessions(); - _giftSeatFlightController.clear(); - } - }); - } - - @override - void dispose() { - final rtmProvider = Provider.of(context, listen: false); - if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) { - rtmProvider.msgFloatingGiftListener = null; - } - _clearLuckyGiftComboSessions(); - _giftSeatFlightController.clear(); - _tabController.dispose(); // 释放资源 - _subscription.cancel(); - super.dispose(); - } - - bool roomThemeBackActi(JoinRoomRes? room) { - if (room?.roomProps?.roomTheme != null) { - if (room?.roomProps?.roomTheme?.themeBack != null && - room!.roomProps!.roomTheme!.themeBack!.isNotEmpty) { - if ((room.roomProps?.roomTheme?.expireTime ?? 0) > - DateTime.now().millisecondsSinceEpoch) { - return true; - } - } - } - return false; - } - - @override - Widget build(BuildContext context) { - _tabs.clear(); - _tabs.add(Tab(text: SCAppLocalizations.of(context)!.all)); - _tabs.add(Tab(text: SCAppLocalizations.of(context)!.chat)); - _tabs.add(Tab(text: SCAppLocalizations.of(context)!.gift)); - return PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, Object? result) { - if (!didPop) { - SCFloatIchart().show(); - SCNavigatorUtils.goBack(context); - } - }, - child: Scaffold( - backgroundColor: - SCGlobalConfig.businessLogicStrategy.getVoiceRoomBackgroundColor(), - resizeToAvoidBottomInset: false, - body: SafeArea( - top: false, - child: Stack( - children: [ - Consumer( - builder: (context, ref, child) { - return roomThemeBackActi(ref.currenRoom) - ? netImage( - url: - ref.currenRoom?.roomProps?.roomTheme?.themeBack ?? - "", - width: ScreenUtil().screenWidth, - height: ScreenUtil().screenHeight, - noDefaultImg: true, - fit: BoxFit.cover, - ) - : Image.asset( - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomDefaultBackgroundImage(), - width: ScreenUtil().screenWidth, - height: ScreenUtil().screenHeight, - fit: BoxFit.cover, - ); - }, - ), - Column( - children: [ - SizedBox(height: ScreenUtil().setWidth(42)), - RoomHeadWidget(), - SizedBox(height: ScreenUtil().setWidth(5)), - RoomOnlineUserWidget(), - RoomSeatWidget(), - SizedBox(height: 2.w), - Expanded( - child: Stack( - children: [ - Column( - children: [_buildChatView(), RoomBottomWidget()], - ), - LGiftAnimalPage(), - Transform.translate( - offset: Offset(0, -20), - child: RoomAnimationQueueScreen(), - ), - ], - ), - ), - ], - ), - // _buildPlayViews(), - ///幸运礼物中奖动画 - LuckGiftNomorAnimWidget(), - ], - ), - ), - ), - ); - } - - ///消息 - Widget _buildChatView() { - return Expanded( - child: Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [ - Column( - children: [ - TabBar( - tabAlignment: TabAlignment.start, - labelPadding: SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabLabelPadding() - .copyWith( - left: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabLabelPadding() - .left * - ScreenUtil().setWidth(1), - right: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabLabelPadding() - .right * - ScreenUtil().setWidth(1), - ), - labelColor: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabLabelColor(), - isScrollable: true, - indicator: BoxDecoration(), - unselectedLabelColor: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabUnselectedLabelColor(), - labelStyle: SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabLabelStyle() - .copyWith( - fontSize: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabLabelStyle() - .fontSize! * - ScreenUtil().setSp(1), - ), - unselectedLabelStyle: SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabUnselectedLabelStyle() - .copyWith( - fontSize: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabUnselectedLabelStyle() - .fontSize! * - ScreenUtil().setSp(1), - ), - indicatorColor: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabIndicatorColor(), - dividerColor: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomTabDividerColor(), - controller: _tabController, - tabs: _tabs, - ), - Expanded( - child: Container( - margin: SCGlobalConfig.businessLogicStrategy - .getVoiceRoomChatContainerMargin() - .copyWith( - end: - SCGlobalConfig.businessLogicStrategy - .getVoiceRoomChatContainerMargin() - .end * - ScreenUtil().setWidth(1), - ), - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: TabBarView( - controller: _tabController, - children: _pages, - ), - ), - ), - ), - ], - ), - RoomPlayWidget(), - ], - ), - ); - } - - ///礼物上飘动画 - _floatingGiftListener(Msg msg) { - if (Provider.of(context, listen: false).hideLGiftAnimal) { - return; - } - var giftModel = LGiftModel(); - final giftTab = (msg.gift?.giftTab ?? '').trim(); - giftModel.labelId = "${msg.gift?.id}${msg.user?.id}${msg.toUser?.id}"; - giftModel.sendUserName = msg.user?.userNickname ?? ""; - giftModel.sendToUserName = msg.toUser?.userNickname ?? ""; - giftModel.sendUserPic = msg.user?.userAvatar ?? ""; - giftModel.giftPic = msg.gift?.giftPhoto ?? ""; - giftModel.giftCount = msg.number ?? 0; - giftModel.isLuckyGift = - giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name; - Provider.of( - context, - listen: false, - ).enqueueGiftAnimation(giftModel); - - final giftPhoto = (msg.gift?.giftPhoto ?? "").trim(); - final targetUserId = _resolveGiftTargetUserId(msg); - final isLuckyGift = _isLuckyGiftMessage(msg); - if (isLuckyGift) { - _handleLuckyGiftComboVisuals(msg, giftPhoto, targetUserId); - return; - } - if (_shouldPlaySeatFlightGiftAnimation(msg) && targetUserId != null) { - _giftSeatFlightController.enqueue( - RoomGiftSeatFlightRequest( - imagePath: giftPhoto, - targetUserId: targetUserId, - beginSize: 96.w, - endSize: 28.w, - ), - ); - } - } - - bool _isLuckyGiftMessage(Msg msg) { - final giftTab = (msg.gift?.giftTab ?? '').trim(); - return giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name; - } - - void _handleLuckyGiftComboVisuals( - Msg msg, - String giftPhoto, - String? targetUserId, - ) { - final quantity = (msg.number ?? 0).floor(); - if (quantity <= 0) { - return; - } - - final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId); - final session = _luckyGiftComboSessions.putIfAbsent( - sessionKey, - () => _LuckyGiftComboSession(), - ); - session.totalCount += quantity; - - final highestMilestone = - SocialChatGiftSystemManager.resolveHighestReachedLuckGiftMilestone( - session.totalCount, - ); - if (highestMilestone != null && - highestMilestone > session.highestPlayedMilestone && - SCGlobalConfig.isLuckGiftSpecialEffects) { - final effectPath = - SocialChatGiftSystemManager.resolveLuckGiftComboEffectPath( - highestMilestone, - ); - if (effectPath != null && effectPath.isNotEmpty) { - SCGiftVapSvgaManager().play(effectPath, priority: 200); - session.highestPlayedMilestone = highestMilestone; - } - } - - if (_shouldPlaySeatFlightGiftAnimation(msg) && - targetUserId != null && - giftPhoto.isNotEmpty) { - session.pendingFlightRequest = RoomGiftSeatFlightRequest( - imagePath: giftPhoto, - targetUserId: targetUserId, - beginSize: 96.w, - endSize: 28.w, - ); - } - session.flushTimer?.cancel(); - session.flushTimer = Timer(_luckyGiftComboWindow, () { - if (!mounted) { - return; - } - final activeSession = _luckyGiftComboSessions.remove(sessionKey); - final pendingFlightRequest = activeSession?.pendingFlightRequest; - activeSession?.dispose(); - if (pendingFlightRequest != null) { - _giftSeatFlightController.enqueue(pendingFlightRequest); - } - }); - } - - String _buildLuckyGiftComboSessionKey(Msg msg, String? targetUserId) { - final senderId = (msg.user?.id ?? '').trim(); - final giftId = (msg.gift?.id ?? '').trim(); - return '$giftId|$senderId|${targetUserId ?? ""}'; - } - - void _clearLuckyGiftComboSessions() { - for (final session in _luckyGiftComboSessions.values) { - session.dispose(); - } - _luckyGiftComboSessions.clear(); - } - - 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; - } -} - -class _LuckyGiftComboSession { - Timer? flushTimer; - int totalCount = 0; - int highestPlayedMilestone = 0; - RoomGiftSeatFlightRequest? pendingFlightRequest; - - void dispose() { - flushTimer?.cancel(); - } -} +import 'dart:math' as math; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/ui_kit/widgets/room/room_bottom_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/shared/tools/sc_lk_event_bus.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +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_gift_vap_svga_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/anim/room_entrance_widget.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'; +import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; +import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart'; +import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart'; +import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart'; + +import '../../ui_kit/components/sc_float_ichart.dart'; +import '../../ui_kit/widgets/room/seat/room_seat_widget.dart'; +import 'chat/all/all_chat_page.dart'; +import 'chat/chat/chat_page.dart'; +import 'chat/gift/gift_chat_page.dart'; + +///语聊房 +class VoiceRoomPage extends StatefulWidget { + const VoiceRoomPage({super.key}); + + @override + State createState() => _VoiceRoomPageState(); +} + +class _VoiceRoomPageState extends State + with SingleTickerProviderStateMixin { + static const Duration _luckyGiftComboWindow = Duration(seconds: 3); + static const Duration _luckyGiftQueueDrainWindow = Duration(seconds: 3); + static const int _maxLuckyGiftTrackedAnimations = 5; + static const Duration _giftAnimationSessionWindow = _luckyGiftComboWindow; + static const Duration _giftAnimationQueueDrainWindow = + _luckyGiftQueueDrainWindow; + static const int _maxTrackedGiftAnimations = _maxLuckyGiftTrackedAnimations; + + late TabController _tabController; + final List _pages = [AllChatPage(), ChatPage(), GiftChatPage()]; + final List _tabs = []; + late StreamSubscription _subscription; + final RoomGiftSeatFlightController _giftSeatFlightController = + RoomGiftSeatFlightController(); + final Map _luckyGiftComboSessions = + {}; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _pages.length, vsync: this); + _enableRoomVisualEffects(); + _tabController.addListener(() {}); // 监听切换 + + _subscription = eventBus.on().listen(( + event, + ) { + if (mounted) { + Provider.of(context, listen: false).clearAllGiftData(); + Provider.of( + context, + listen: false, + ).toggleGiftAnimationVisibility(false); + _clearLuckyGiftComboSessions(); + _giftSeatFlightController.clear(); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _ensureRoomVisualEffectsEnabled(); + } + + @override + void dispose() { + _suspendRoomVisualEffects(); + _tabController.dispose(); // 释放资源 + _subscription.cancel(); + super.dispose(); + } + + void _enableRoomVisualEffects() { + Provider.of( + context, + listen: false, + ).setRoomVisualEffectsEnabled(true); + Provider.of(context, listen: false).msgFloatingGiftListener = + _floatingGiftListener; + } + + void _ensureRoomVisualEffectsEnabled() { + final rtcProvider = Provider.of(context, listen: false); + if (rtcProvider.currenRoom == null || + rtcProvider.roomVisualEffectsEnabled) { + return; + } + _enableRoomVisualEffects(); + } + + void _suspendRoomVisualEffects() { + final rtcProvider = Provider.of(context, listen: false); + rtcProvider.setRoomVisualEffectsEnabled(false); + + final rtmProvider = Provider.of(context, listen: false); + if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) { + rtmProvider.msgFloatingGiftListener = null; + } + + RoomEntranceHelper.clearQueue(); + _clearLuckyGiftComboSessions(); + _giftSeatFlightController.clear(); + SCGiftVapSvgaManager().stopPlayback(); + } + + bool roomThemeBackActi(JoinRoomRes? room) { + if (room?.roomProps?.roomTheme != null) { + if (room?.roomProps?.roomTheme?.themeBack != null && + room!.roomProps!.roomTheme!.themeBack!.isNotEmpty) { + if ((room.roomProps?.roomTheme?.expireTime ?? 0) > + DateTime.now().millisecondsSinceEpoch) { + return true; + } + } + } + return false; + } + + @override + Widget build(BuildContext context) { + _tabs.clear(); + _tabs.add(Tab(text: SCAppLocalizations.of(context)!.all)); + _tabs.add(Tab(text: SCAppLocalizations.of(context)!.chat)); + _tabs.add(Tab(text: SCAppLocalizations.of(context)!.gift)); + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (!didPop) { + _suspendRoomVisualEffects(); + SCFloatIchart().show(); + SCNavigatorUtils.goBack(context); + } + }, + child: Scaffold( + backgroundColor: + SCGlobalConfig.businessLogicStrategy.getVoiceRoomBackgroundColor(), + resizeToAvoidBottomInset: false, + body: SafeArea( + top: false, + child: Stack( + children: [ + Consumer( + builder: (context, ref, child) { + return roomThemeBackActi(ref.currenRoom) + ? netImage( + url: + ref.currenRoom?.roomProps?.roomTheme?.themeBack ?? + "", + width: ScreenUtil().screenWidth, + height: ScreenUtil().screenHeight, + noDefaultImg: true, + fit: BoxFit.cover, + ) + : Image.asset( + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomDefaultBackgroundImage(), + width: ScreenUtil().screenWidth, + height: ScreenUtil().screenHeight, + fit: BoxFit.cover, + ); + }, + ), + Column( + children: [ + SizedBox(height: ScreenUtil().setWidth(42)), + RoomHeadWidget(), + SizedBox(height: ScreenUtil().setWidth(5)), + RoomOnlineUserWidget(), + RoomSeatWidget(), + SizedBox(height: 2.w), + Expanded( + child: Stack( + children: [ + Column( + children: [_buildChatView(), RoomBottomWidget()], + ), + LGiftAnimalPage(), + Transform.translate( + offset: Offset(0, -20), + child: RoomAnimationQueueScreen(), + ), + ], + ), + ), + ], + ), + // _buildPlayViews(), + ///幸运礼物中奖动画 + LuckGiftNomorAnimWidget(), + ], + ), + ), + ), + ); + } + + ///消息 + Widget _buildChatView() { + return Expanded( + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + Column( + children: [ + TabBar( + tabAlignment: TabAlignment.start, + labelPadding: SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabLabelPadding() + .copyWith( + left: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabLabelPadding() + .left * + ScreenUtil().setWidth(1), + right: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabLabelPadding() + .right * + ScreenUtil().setWidth(1), + ), + labelColor: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabLabelColor(), + isScrollable: true, + indicator: BoxDecoration(), + unselectedLabelColor: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabUnselectedLabelColor(), + labelStyle: SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabLabelStyle() + .copyWith( + fontSize: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabLabelStyle() + .fontSize! * + ScreenUtil().setSp(1), + ), + unselectedLabelStyle: SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabUnselectedLabelStyle() + .copyWith( + fontSize: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabUnselectedLabelStyle() + .fontSize! * + ScreenUtil().setSp(1), + ), + indicatorColor: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabIndicatorColor(), + dividerColor: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomTabDividerColor(), + controller: _tabController, + tabs: _tabs, + ), + Expanded( + child: Container( + margin: SCGlobalConfig.businessLogicStrategy + .getVoiceRoomChatContainerMargin() + .copyWith( + end: + SCGlobalConfig.businessLogicStrategy + .getVoiceRoomChatContainerMargin() + .end * + ScreenUtil().setWidth(1), + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: TabBarView( + controller: _tabController, + children: _pages, + ), + ), + ), + ), + ], + ), + RoomPlayWidget(), + ], + ), + ); + } + + ///礼物上飘动画 + _floatingGiftListener(Msg msg) { + if (!Provider.of( + context, + listen: false, + ).shouldShowRoomVisualEffects) { + return; + } + if (Provider.of(context, listen: false).hideLGiftAnimal) { + return; + } + var giftModel = LGiftModel(); + final giftTab = (msg.gift?.giftTab ?? '').trim(); + giftModel.labelId = "${msg.gift?.id}${msg.user?.id}${msg.toUser?.id}"; + giftModel.sendUserName = msg.user?.userNickname ?? ""; + giftModel.sendToUserName = msg.toUser?.userNickname ?? ""; + giftModel.sendUserPic = msg.user?.userAvatar ?? ""; + giftModel.giftPic = msg.gift?.giftPhoto ?? ""; + giftModel.giftCount = msg.number ?? 0; + giftModel.isLuckyGift = + giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name; + Provider.of( + context, + listen: false, + ).enqueueGiftAnimation(giftModel); + + final giftPhoto = (msg.gift?.giftPhoto ?? "").trim(); + final targetUserId = _resolveGiftTargetUserId(msg); + if (_supportsComboMilestoneEffects(msg)) { + _handleComboMilestoneVisuals(msg, targetUserId); + } + final isLuckyGift = _isLuckyGiftMessage(msg); + if (isLuckyGift) { + _handleLuckyGiftComboVisuals(msg, giftPhoto, targetUserId); + return; + } + _handleStandardGiftComboVisuals(msg, giftPhoto, targetUserId); + } + + bool _isLuckyGiftMessage(Msg msg) { + final giftTab = (msg.gift?.giftTab ?? '').trim(); + return giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name; + } + + bool _supportsComboMilestoneEffects(Msg msg) { + return SocialChatGiftSystemManager.supportsComboMilestoneEffects(msg.gift); + } + + bool _shouldPlayComboMilestoneEffect(Msg msg) { + if (!_supportsComboMilestoneEffects(msg)) { + return false; + } + return SCGlobalConfig.isLuckGiftSpecialEffects; + } + + void _handleComboMilestoneVisuals(Msg msg, String? targetUserId) { + final quantity = (msg.number ?? 0).floor(); + if (quantity <= 0) { + return; + } + + final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId); + final session = _luckyGiftComboSessions.putIfAbsent( + sessionKey, + () => _LuckyGiftComboSession(), + ); + session.endTimer?.cancel(); + session.clearQueueTimer?.cancel(); + session.totalCount += quantity; + + final highestMilestone = + SocialChatGiftSystemManager.resolveHighestReachedComboMilestone( + session.totalCount, + ); + if (highestMilestone != null && + highestMilestone > session.highestPlayedMilestone && + _shouldPlayComboMilestoneEffect(msg)) { + final effectPath = + SocialChatGiftSystemManager.resolveComboMilestoneEffectPath( + highestMilestone, + ); + if (effectPath != null && effectPath.isNotEmpty) { + SCGiftVapSvgaManager().play(effectPath, priority: 200); + session.highestPlayedMilestone = highestMilestone; + } + } + } + + void _handleStandardGiftComboVisuals( + Msg msg, + String giftPhoto, + String? targetUserId, + ) { + if ((msg.number ?? 0) <= 0) { + return; + } + if (!_shouldPlaySeatFlightGiftAnimation(msg) || + targetUserId == null || + giftPhoto.isEmpty) { + return; + } + final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId); + final session = _luckyGiftComboSessions.putIfAbsent( + sessionKey, + () => _LuckyGiftComboSession(), + ); + session.endTimer?.cancel(); + session.clearQueueTimer?.cancel(); + + _enqueueTrackedSeatFlightAnimations( + sessionKey: sessionKey, + giftPhoto: giftPhoto, + targetUserId: targetUserId, + animationCount: _resolveTrackedAnimationCount(msg), + ); + + _scheduleGiftAnimationSessionEnd(sessionKey); + } + + void _handleLuckyGiftComboVisuals( + Msg msg, + String giftPhoto, + String? targetUserId, + ) { + if ((msg.number ?? 0) <= 0) { + return; + } + final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId); + final session = _luckyGiftComboSessions[sessionKey]; + if (session == null) { + return; + } + session.endTimer?.cancel(); + session.clearQueueTimer?.cancel(); + + if (_shouldPlaySeatFlightGiftAnimation(msg) && + targetUserId != null && + giftPhoto.isNotEmpty) { + _enqueueTrackedSeatFlightAnimations( + sessionKey: sessionKey, + giftPhoto: giftPhoto, + targetUserId: targetUserId, + animationCount: _resolveTrackedAnimationCount(msg), + ); + } + _scheduleGiftAnimationSessionEnd(sessionKey); + } + + int _resolveTrackedAnimationCount(Msg msg) { + return math.max(msg.customAnimationCount ?? 1, 1); + } + + void _enqueueTrackedSeatFlightAnimations({ + required String sessionKey, + required String giftPhoto, + required String targetUserId, + required int animationCount, + }) { + final normalizedAnimationCount = math.max(animationCount, 1); + final cappedAnimationCount = math.min( + normalizedAnimationCount, + _maxTrackedGiftAnimations, + ); + for (var index = 0; index < cappedAnimationCount; index += 1) { + _giftSeatFlightController.enqueueLimited( + RoomGiftSeatFlightRequest( + imagePath: giftPhoto, + targetUserId: targetUserId, + beginSize: 96.w, + endSize: 28.w, + queueTag: sessionKey, + ), + maxTrackedRequests: _maxTrackedGiftAnimations, + ); + } + } + + void _scheduleGiftAnimationSessionEnd(String sessionKey) { + final session = _luckyGiftComboSessions[sessionKey]; + if (session == null) { + return; + } + session.endTimer?.cancel(); + session.endTimer = Timer(_giftAnimationSessionWindow, () { + if (!mounted) { + return; + } + final activeSession = _luckyGiftComboSessions[sessionKey]; + if (activeSession == null) { + return; + } + if (!_giftSeatFlightController.hasTrackedRequests(sessionKey)) { + activeSession.dispose(); + _luckyGiftComboSessions.remove(sessionKey); + return; + } + activeSession.clearQueueTimer?.cancel(); + activeSession.clearQueueTimer = Timer(_giftAnimationQueueDrainWindow, () { + _giftSeatFlightController.clearQueuedRequests(sessionKey); + final expiredSession = _luckyGiftComboSessions.remove(sessionKey); + expiredSession?.dispose(); + }); + }); + } + + String _buildLuckyGiftComboSessionKey(Msg msg, String? targetUserId) { + final senderId = (msg.user?.id ?? '').trim(); + final giftId = (msg.gift?.id ?? '').trim(); + return '$giftId|$senderId|${targetUserId ?? ""}'; + } + + void _clearLuckyGiftComboSessions() { + for (final session in _luckyGiftComboSessions.values) { + session.dispose(); + } + _luckyGiftComboSessions.clear(); + } + + 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; + } +} + +class _LuckyGiftComboSession { + Timer? endTimer; + Timer? clearQueueTimer; + int totalCount = 0; + int highestPlayedMilestone = 0; + + void dispose() { + endTimer?.cancel(); + clearQueueTimer?.cancel(); + } +} diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 1ae2168..bb86445 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -45,6 +45,7 @@ typedef RtcProvider = RealTimeCommunicationManager; class RealTimeCommunicationManager extends ChangeNotifier { bool needUpDataUserInfo = false; + bool _roomVisualEffectsEnabled = false; ///当前所在房间 JoinRoomRes? currenRoom; @@ -88,6 +89,19 @@ class RealTimeCommunicationManager extends ChangeNotifier { this.context = context; } + bool get roomVisualEffectsEnabled => _roomVisualEffectsEnabled; + + bool get shouldShowRoomVisualEffects => + currenRoom != null && _roomVisualEffectsEnabled; + + void setRoomVisualEffectsEnabled(bool enabled) { + if (_roomVisualEffectsEnabled == enabled) { + return; + } + _roomVisualEffectsEnabled = enabled; + notifyListeners(); + } + Future joinAgoraVoiceChannel() async { try { engine = await _initAgoraRtcEngine(); @@ -329,6 +343,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { listen: false, ).fetchUserProfileData(); retrieveMicrophoneList(); + setRoomVisualEffectsEnabled(true); SCFloatIchart().remove(); SCNavigatorUtils.push( context, @@ -410,6 +425,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { notifyListeners(); }); } + setRoomVisualEffectsEnabled(true); SCNavigatorUtils.push(context!, VoiceRoomRoute.voiceRoom, replace: false); var joinResult = await rtmProvider?.joinRoomGroup( currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", @@ -455,7 +471,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { rtmProvider?.dispatchMessage(joinMsg, addLocal: true); if (SCGlobalConfig.isEntryVehicleAnimation) { if (AccountStorage().getCurrentUser()?.userProfile?.getMountains() != - null) { + null && + shouldShowRoomVisualEffects) { Future.delayed(Duration(milliseconds: 550), () { SCGiftVapSvgaManager().play( AccountStorage() @@ -566,6 +583,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { rtmProvider?.msgGiftListener = null; SCLoadingManager.show(context: context); SCGiftVapSvgaManager().stopPlayback(); + setRoomVisualEffectsEnabled(false); SCHeartbeatUtils.scheduleHeartbeat(SCHeartbeatStatus.ONLINE.name, false); SCHeartbeatUtils.cancelAnchorTimer(); rtmProvider?.cleanRoomData(); @@ -630,6 +648,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { rtmProvider?.roomChatMsgList.clear(); redPacketList.clear(); currenRoom = null; + _roomVisualEffectsEnabled = false; isMic = true; isMusicPlaying = false; DataPersistence.setLastTimeRoomId(""); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index eb57da3..8841eac 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -1284,6 +1284,11 @@ class RealTimeMessagingManager extends ChangeNotifier { } } else { if (msg.type == SCRoomMsgType.joinRoom) { + final shouldShowRoomVisualEffects = + Provider.of( + context!, + listen: false, + ).shouldShowRoomVisualEffects; if (msg.user != null) { Provider.of( context!, @@ -1296,7 +1301,8 @@ class RealTimeMessagingManager extends ChangeNotifier { ///坐骑 if (msg.user?.getMountains() != null) { - if (SCGlobalConfig.isEntryVehicleAnimation) { + if (SCGlobalConfig.isEntryVehicleAnimation && + shouldShowRoomVisualEffects) { SCGiftVapSvgaManager().play( msg.user?.getMountains()?.sourceUrl ?? "", priority: 100, @@ -1330,7 +1336,11 @@ class RealTimeMessagingManager extends ChangeNotifier { ); if (msg.gift!.giftSourceUrl != null && msg.gift!.special != null) { if (scGiftHasFullScreenEffect(msg.gift!.special)) { - if (SCGlobalConfig.isGiftSpecialEffects) { + if (SCGlobalConfig.isGiftSpecialEffects && + Provider.of( + context!, + listen: false, + ).shouldShowRoomVisualEffects) { _giftFxLog( 'trigger player play path=${msg.gift!.giftSourceUrl} ' 'giftId=${msg.gift?.id} giftName=${msg.gift?.giftName}', @@ -1338,8 +1348,10 @@ class RealTimeMessagingManager extends ChangeNotifier { SCGiftVapSvgaManager().play(msg.gift!.giftSourceUrl!); } else { _giftFxLog( - 'skip player play because isGiftSpecialEffects=false ' - 'giftId=${msg.gift?.id}', + 'skip player play because visual effects disabled ' + 'giftId=${msg.gift?.id} ' + 'isGiftSpecialEffects=${SCGlobalConfig.isGiftSpecialEffects} ' + 'roomVisible=${Provider.of(context!, listen: false).shouldShowRoomVisualEffects}', ); } } else { diff --git a/lib/services/gift/gift_system_manager.dart b/lib/services/gift/gift_system_manager.dart index e10b594..a02080d 100644 --- a/lib/services/gift/gift_system_manager.dart +++ b/lib/services/gift/gift_system_manager.dart @@ -4,6 +4,7 @@ import 'package:yumi/shared/business_logic/models/res/mic_res.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; import 'package:yumi/shared/business_logic/models/res/gift_res.dart'; +import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart'; typedef GiftProvider = SocialChatGiftSystemManager; @@ -210,7 +211,7 @@ class SocialChatGiftSystemManager extends ChangeNotifier { } void startGiftAnimation() { - final milestone = resolveHighestReachedLuckGiftMilestone(number); + final milestone = resolveHighestReachedComboMilestone(number); if (milestone == null || (isPlayed[milestone] ?? false)) { return; } @@ -221,7 +222,7 @@ class SocialChatGiftSystemManager extends ChangeNotifier { void playVisualEffect(num n) { if (!(isPlayed[n] ?? false)) { if (SCGlobalConfig.isLuckGiftSpecialEffects) { - final comboEffectPath = resolveLuckGiftComboEffectPath(n); + final comboEffectPath = resolveComboMilestoneEffectPath(n); if (comboEffectPath != null) { SCGiftVapSvgaManager().play(comboEffectPath, priority: 200); } @@ -239,7 +240,12 @@ class SocialChatGiftSystemManager extends ChangeNotifier { } } - static int? resolveHighestReachedLuckGiftMilestone(num count) { + static bool supportsComboMilestoneEffects(SocialChatGiftRes? gift) { + final giftTab = (gift?.giftTab ?? '').trim(); + return giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name; + } + + static int? resolveHighestReachedComboMilestone(num count) { final normalizedCount = count.floor(); int? highest; for (final milestone in _luckGiftMilestones) { @@ -251,7 +257,11 @@ class SocialChatGiftSystemManager extends ChangeNotifier { return highest; } - static String? resolveLuckGiftComboEffectPath(num count) { + static int? resolveHighestReachedLuckGiftMilestone(num count) { + return resolveHighestReachedComboMilestone(count); + } + + static String? resolveComboMilestoneEffectPath(num count) { if (count % 1 != 0) { return null; } @@ -265,4 +275,8 @@ class SocialChatGiftSystemManager extends ChangeNotifier { } return "sc_images/room/anim/luck_gift_count_$normalizedCount.mp4"; } + + static String? resolveLuckGiftComboEffectPath(num count) { + return resolveComboMilestoneEffectPath(count); + } } diff --git a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart index 67e0fa3..4d6b265 100644 --- a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart +++ b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart @@ -5,10 +5,10 @@ import 'package:yumi/app_localizations.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:provider/provider.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; - class RoomAnimationQueueScreen extends StatefulWidget { const RoomAnimationQueueScreen({super.key}); @@ -31,11 +31,17 @@ class _RoomAnimationQueueScreenState extends State { _msgUserJoinListener(Msg msg) { Future.delayed(Duration(milliseconds: 550), () { + if (!_effectsEnabled) { + return; + } _addToQueue(msg); }); } void _addToQueue(Msg msg) { + if (!_effectsEnabled || !mounted) { + return; + } setState(() { final taskId = DateTime.now().millisecondsSinceEpoch; final animationKey = GlobalKey<_RoomEntranceAnimationState>(); @@ -96,6 +102,12 @@ class _RoomAnimationQueueScreenState extends State { } void _clearQueue() { + if (!mounted) { + _animationQueue.clear(); + _animationKeys.clear(); + _isQueueProcessing = false; + return; + } setState(() { _animationQueue.clear(); _animationKeys.clear(); @@ -103,8 +115,40 @@ class _RoomAnimationQueueScreenState extends State { }); } + bool get _effectsEnabled => + Provider.of( + context, + listen: false, + ).shouldShowRoomVisualEffects; + + @override + void dispose() { + final rtmProvider = Provider.of(context, listen: false); + if (rtmProvider.msgUserJoinListener == _msgUserJoinListener) { + rtmProvider.msgUserJoinListener = null; + } + _animationQueue.clear(); + _animationKeys.clear(); + _isQueueProcessing = false; + super.dispose(); + } + @override Widget build(BuildContext context) { + final effectsEnabled = context.select( + (rtcProvider) => rtcProvider.shouldShowRoomVisualEffects, + ); + if (!effectsEnabled) { + if (_animationQueue.isNotEmpty || _isQueueProcessing) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _clearQueue(); + } + }); + } + return const SizedBox.shrink(); + } + return Padding( padding: const EdgeInsets.all(20.0), child: Column( @@ -218,9 +262,7 @@ class _RoomEntranceAnimationState extends State height: 37.w, decoration: BoxDecoration( image: DecorationImage( - image: AssetImage( - getEntranceBg(""), - ), + image: AssetImage(getEntranceBg("")), fit: BoxFit.fill, ), ), 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 index 55cb99d..2c3eeae 100644 --- 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 @@ -17,6 +17,7 @@ class RoomGiftSeatFlightRequest { this.flightDuration = const Duration(milliseconds: 920), this.beginSize = 96, this.endSize = 34, + this.queueTag, }); final String imagePath; @@ -25,6 +26,7 @@ class RoomGiftSeatFlightRequest { final Duration flightDuration; final double beginSize; final double endSize; + final String? queueTag; } class RoomGiftSeatFlightController { @@ -39,21 +41,11 @@ class RoomGiftSeatFlightController { _RoomGiftSeatFlightOverlayState? _state; void enqueue(RoomGiftSeatFlightRequest request) { - final imagePath = request.imagePath.trim(); - final targetUserId = request.targetUserId.trim(); - if (imagePath.isEmpty || targetUserId.isEmpty) { + final normalizedRequest = _normalizeRequest(request); + if (normalizedRequest == null) { 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; @@ -61,11 +53,122 @@ class RoomGiftSeatFlightController { _state!._enqueue(normalizedRequest); } + void enqueueLimited( + RoomGiftSeatFlightRequest request, { + required int maxTrackedRequests, + }) { + final normalizedRequest = _normalizeRequest(request); + if (normalizedRequest == null) { + return; + } + + final queueTag = (normalizedRequest.queueTag ?? '').trim(); + if (queueTag.isEmpty || maxTrackedRequests <= 0) { + enqueue(normalizedRequest); + return; + } + + if (_state == null) { + _trimPendingRequestsForTag(queueTag, maxTrackedRequests); + _pendingRequests.add(normalizedRequest); + return; + } + _state!._enqueueLimited( + normalizedRequest, + maxTrackedRequests: maxTrackedRequests, + ); + } + void clear() { _pendingRequests.clear(); _state?._clear(); } + bool hasTrackedRequests(String queueTag) { + final normalizedQueueTag = queueTag.trim(); + if (normalizedQueueTag.isEmpty) { + return false; + } + if (_state == null) { + return _pendingRequests.any( + (request) => (request.queueTag ?? '').trim() == normalizedQueueTag, + ); + } + return _state!._hasTrackedRequests(normalizedQueueTag); + } + + void clearQueuedRequests(String queueTag) { + final normalizedQueueTag = queueTag.trim(); + if (normalizedQueueTag.isEmpty) { + return; + } + if (_state == null) { + _clearPendingRequestsForTag(normalizedQueueTag); + return; + } + _state!._clearQueuedRequests(normalizedQueueTag); + } + + RoomGiftSeatFlightRequest? _normalizeRequest( + RoomGiftSeatFlightRequest request, + ) { + final imagePath = request.imagePath.trim(); + final targetUserId = request.targetUserId.trim(); + if (imagePath.isEmpty || targetUserId.isEmpty) { + return null; + } + + final queueTag = request.queueTag?.trim(); + return RoomGiftSeatFlightRequest( + imagePath: imagePath, + targetUserId: targetUserId, + holdDuration: request.holdDuration, + flightDuration: request.flightDuration, + beginSize: request.beginSize, + endSize: request.endSize, + queueTag: queueTag == null || queueTag.isEmpty ? null : queueTag, + ); + } + + void _trimPendingRequestsForTag(String queueTag, int maxTrackedRequests) { + while (_countPendingRequestsForTag(queueTag) >= maxTrackedRequests) { + final removed = _removeOldestPendingRequestForTag(queueTag); + if (!removed) { + break; + } + } + } + + int _countPendingRequestsForTag(String queueTag) { + var count = 0; + for (final request in _pendingRequests) { + if ((request.queueTag ?? '').trim() == queueTag) { + count += 1; + } + } + return count; + } + + bool _removeOldestPendingRequestForTag(String queueTag) { + for (final request in _pendingRequests.toList()) { + if ((request.queueTag ?? '').trim() == queueTag) { + _pendingRequests.remove(request); + return true; + } + } + return false; + } + + void _clearPendingRequestsForTag(String queueTag) { + final requestsToRemove = + _pendingRequests + .where((request) => (request.queueTag ?? '').trim() == queueTag) + .toList(); + for (final request in requestsToRemove) { + _pendingRequests.remove(request); + } + } + void _attach(_RoomGiftSeatFlightOverlayState state) { _state = state; while (_pendingRequests.isNotEmpty) { @@ -139,6 +242,26 @@ class _RoomGiftSeatFlightOverlayState extends State _scheduleNextAnimation(); } + void _enqueueLimited( + RoomGiftSeatFlightRequest request, { + required int maxTrackedRequests, + }) { + final queueTag = (request.queueTag ?? '').trim(); + if (queueTag.isEmpty) { + _enqueue(request); + return; + } + + while (_countTrackedRequests(queueTag) >= maxTrackedRequests) { + final removed = _removeOldestQueuedRequest(queueTag); + if (!removed) { + return; + } + } + + _enqueue(request); + } + void _clear() { _queue.clear(); _controller.stop(); @@ -158,6 +281,54 @@ class _RoomGiftSeatFlightOverlayState extends State }); } + bool _hasTrackedRequests(String queueTag) { + if ((_activeRequest?.queueTag ?? '').trim() == queueTag) { + return true; + } + for (final queuedRequest in _queue) { + if ((queuedRequest.request.queueTag ?? '').trim() == queueTag) { + return true; + } + } + return false; + } + + void _clearQueuedRequests(String queueTag) { + final requestsToRemove = + _queue + .where( + (queuedRequest) => + (queuedRequest.request.queueTag ?? '').trim() == queueTag, + ) + .toList(); + for (final queuedRequest in requestsToRemove) { + _queue.remove(queuedRequest); + } + } + + int _countTrackedRequests(String queueTag) { + var count = 0; + if ((_activeRequest?.queueTag ?? '').trim() == queueTag) { + count += 1; + } + for (final queuedRequest in _queue) { + if ((queuedRequest.request.queueTag ?? '').trim() == queueTag) { + count += 1; + } + } + return count; + } + + bool _removeOldestQueuedRequest(String queueTag) { + for (final queuedRequest in _queue.toList()) { + if ((queuedRequest.request.queueTag ?? '').trim() == queueTag) { + _queue.remove(queuedRequest); + return true; + } + } + return false; + } + void _scheduleNextAnimation() { if (_isPlaying || _queue.isEmpty || !mounted) { return; diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index 37efd8c..9809f85 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -206,8 +206,8 @@ class _RoomBottomWidgetState extends State { child: RoomBottomCircleAction( child: Image.asset( "sc_images/room/${provider.isMic ? 'sc_icon_botton_mic_close' : 'sc_icon_botton_mic_open'}.png", - width: 24.w, - height: 24.w, + width: 30.w, + height: 30.w, fit: BoxFit.contain, gaplessPlayback: true, ), diff --git a/lib/ui_kit/widgets/room/room_msg_item.dart b/lib/ui_kit/widgets/room/room_msg_item.dart index a71d55f..9c74719 100644 --- a/lib/ui_kit/widgets/room/room_msg_item.dart +++ b/lib/ui_kit/widgets/room/room_msg_item.dart @@ -1299,6 +1299,9 @@ class Msg { //礼物个数 num? number; + // 自定义动画需要播放的次数 + int? customAnimationCount; + num? awardAmount; // 送礼给谁 @@ -1320,6 +1323,7 @@ class Msg { this.role = "", this.gift, this.number, + this.customAnimationCount, this.awardAmount, this.hasPlayedAnimation, this.needUpDataUserInfo, @@ -1334,6 +1338,7 @@ class Msg { role = json['role']; time = json['time']; number = json['number']; + customAnimationCount = json['customAnimationCount']; awardAmount = json['awardAmount']; hasPlayedAnimation = json['hasPlayedAnimation']; needUpDataUserInfo = json['needUpDataUserInfo']; @@ -1361,6 +1366,7 @@ class Msg { map['time'] = time; map['awardAmount'] = awardAmount; map['number'] = number; + map['customAnimationCount'] = customAnimationCount; map['hasPlayedAnimation'] = hasPlayedAnimation; map['needUpDataUserInfo'] = needUpDataUserInfo; if (userWheat != null) { diff --git a/scripts/build_release.py b/scripts/build_release.py index c423786..bf17a44 100644 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -181,11 +181,12 @@ def build_android(args: argparse.Namespace, manifest: dict[str, object]) -> None "flutter", "build", "apk", + "--split-per-abi", "--target-platform", "android-arm64", ] - append_common_flutter_args(apk_cmd, args, build_mode="debug") - with timed_stage(manifest, "android.localDebugApk", "Android 极速测试包(Debug / ARM64)"): + append_common_flutter_args(apk_cmd, args) + with timed_stage(manifest, "android.localReleaseApk", "Android 测试包(Release / ARM64)"): run_command(apk_cmd) google_play_dir = android_output_dir / "google-play" @@ -205,12 +206,12 @@ def build_android(args: argparse.Namespace, manifest: dict[str, object]) -> None artifacts["localArm64Apk"] = copy_file(arm64_src, local_dir / f"{artifact_prefix}-arm64-v8a.apk") elif profile == "local-arm64": arm64_src = first_existing_path( - ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-arm64-v8a-debug.apk", - ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-debug.apk", + ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-arm64-v8a-release.apk", + ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-release.apk", ) artifacts["localArm64Apk"] = copy_file( arm64_src, - local_dir / f"{artifact_prefix}-arm64-v8a-debug.apk", + local_dir / f"{artifact_prefix}-arm64-v8a.apk", include_sha256=False, ) diff --git a/需求进度.md b/需求进度.md index c586b91..e9e0f77 100644 --- a/需求进度.md +++ b/需求进度.md @@ -15,7 +15,8 @@ ## 已完成模块 - 已继续优化幸运/CP 礼物连击体验:房间 `Gift/All` 消息面板现在会对短时间内同一发送者、同一目标、同一礼物的连续赠送做聚合更新,不再每点一次就追加一条新消息,而是复用同一条礼物播报并持续刷新成 `xN`;同时礼物页底部发送按钮已补上本地连击反馈,Lucky/CP/Magic 类礼物连续点击时会显示一条从右向左收缩的浅色倒计时渐变条,并同步累加当前连击数量,用户能更直观看到连击窗口是否还在持续。 - 已继续给发送端补齐连击请求聚合:礼物页当前会对 Lucky/CP/Magic 这类连击型礼物启用约 `200ms` 的本地批量窗口,用户在短时间内连续点击 `Send` 时,前端会先把同一礼物、同一目标集合、同一房间的点击数量累加到同一批次里,再统一发一次接口和一次房间 RTM 消息;这样高连击时不再按点击次数直冲 `/gift/batch` 或 `/gift/give/lucky-gift`,同时也避免本地回显和房间消息量被线性放大。 -- 已继续收敛幸运礼物高连击时的播放策略:房间内幸运礼物现在按 3 秒连击会话聚合,连击期间只更新房内上飘与总次数,不再每次都立刻飞向麦位;同一轮连击结束后才会向目标麦位补播一次飞行动画,避免 `1000` 连击把静态礼物飞行动画排成超长队列;同时幸运礼物的档位特效已改为只命中当前会话累计数量对应的最高有效档位,不再把中间跨过的 `10/20/30/...` 全部补播一遍。 +- 已按最新确认回调幸运礼物的“自制飞向麦位动画”策略:不再等整轮连击结束后只飞一次,而是改回按点击次数触发;为兼顾连击体感和队列安全,当前会通过 RTM 额外携带本批次对应的点击次数,让房间端按点击数补齐自制动画,同时同一轮幸运礼物连击的飞行动画队列最多只保留 `5` 个;当连击在 `3s` 内停止后,如果队列里还有待播动画,则只再给 `3s` 消化时间,超时后剩余待播动画会直接清掉。 +- 已继续收敛幸运礼物高连击时的播放策略:房间内幸运礼物仍按 3 秒连击会话累计总次数,用来判断当前命中的最高有效档位特效;但自制飞向麦位动画已改回按点击次数触发,并通过“同会话最多保留 5 个待播/在播动画 + 连击结束后额外 3 秒清尾”的方式限制队列膨胀,不再让 `1000` 连击把静态礼物飞行动画排成超长队列;同时幸运礼物的档位特效仍保持只命中当前会话累计数量对应的最高有效档位,不再把中间跨过的 `10/20/30/...` 全部补播一遍。 - 已定位到幸运礼物“中奖通知”使用的是房间页顶层 `LuckGiftNomorAnimWidget`,此前背景一直是静态 `sc_icon_luck_gift_nomore.webp`;当前已改为优先播放本地 `sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga`,并保留原 `webp` 作为失败兜底,这样幸运礼物中奖弹层会直接复用新的奖励边框动效资源。 - 已开始接入幸运礼物连击档位的新动效资源:桌面“幸运礼物相关”目录下的 SVGA 已统一导入到 `sc_images/room/anim/luck_gift/`,并按规范重命名为 `luck_gift_combo_count_10.svga / luck_gift_combo_count_666.svga / luck_gift_combo_count_10000.svga` 这一类统一英文命名;当前连击阈值触发仍复用 `GiftSystemManager.playVisualEffect()`,命中已提供素材的档位时优先播放新的本地 SVGA,未提供素材的档位继续保留旧兜底逻辑,避免影响现有幸运礼物连击链路。 - 已将语言房送礼链路接入新的“中心停留后飞向目标麦位”组件,但只对无自带特效的静态 PNG 礼物生效:当前带自身 `SVGA/MP4/VAP` 动画或被识别为全屏礼物特效的礼物保持原有播放逻辑不变;只有普通 PNG 礼物会额外触发“屏幕中央停留 -> 三连残影飞向被赠送麦位”的补充动效,避免和自带礼物特效重复叠播。