diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index a32333d..1f43643 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -120,6 +120,7 @@ class RealTimeMessagingManager extends ChangeNotifier { static const int _luckyGiftBurstPlaybackWatchdogMs = 8000; static const int _roomRocketLaunchRecentTtlMs = 90000; static const int _roomRocketLaunchLooseRecentTtlMs = 12000; + static const int _roomRocketRegionBroadcastRecentTtlMs = 90000; static const List _roomRocketRewardPopupRetryDelays = [ Duration(milliseconds: 700), Duration(seconds: 2), @@ -235,6 +236,8 @@ class RealTimeMessagingManager extends ChangeNotifier { final Map _recentLuckGiftPushEventTimes = {}; final Map _recentRoomRocketLaunchTimes = {}; final Map _recentRoomRocketLaunchLooseTimes = {}; + final Map _recentRoomRocketRegionBroadcastTimes = + {}; Timer? _roomRocketRewardPopupRetryTimer; int get currentLuckGiftBurstPlaybackToken => _luckGiftPushPlaybackToken; String? roomRedPacketBroadcastGroupId; @@ -1936,16 +1939,19 @@ class RealTimeMessagingManager extends ChangeNotifier { payload: data, roomId: roomId, ); - if (isCurrentVisibleRoom && - _shouldHandleRoomRocketLaunchFromStatusPayload( + final shouldHandleLaunch = _shouldHandleRoomRocketLaunchFromStatusPayload( + rtcProvider: rtcProvider, + payload: data, + launch: launch, + ); + if (shouldHandleLaunch) { + _publishRoomRocketRegionBroadcastIfNeeded(launch); + if (isCurrentVisibleRoom) { + _handleCurrentVisibleRoomRocketLaunch( rtcProvider: rtcProvider, - payload: data, launch: launch, - )) { - _handleCurrentVisibleRoomRocketLaunch( - rtcProvider: rtcProvider, - launch: launch, - ); + ); + } } unawaited(rtcProvider.refreshRoomRocketStatus(roomId: roomId)); } @@ -1977,6 +1983,9 @@ class RealTimeMessagingManager extends ChangeNotifier { rtcProvider.shouldShowRoomVisualEffects && rtcProvider.isVoiceRoomRouteVisible; if (isCurrentVisibleRoom) { + if (!isRegionBroadcast) { + _publishRoomRocketRegionBroadcastIfNeeded(launch); + } _handleCurrentVisibleRoomRocketLaunch( rtcProvider: rtcProvider, launch: launch, @@ -1986,6 +1995,7 @@ class RealTimeMessagingManager extends ChangeNotifier { } if (isCurrentRoom && !isCurrentVisibleRoom && !isRegionBroadcast) { + _publishRoomRocketRegionBroadcastIfNeeded(launch); return; } OverlayManager().addMessage( @@ -2056,6 +2066,47 @@ class RealTimeMessagingManager extends ChangeNotifier { unawaited(_showRoomRocketRewardPopupDialog(launch.roomId)); } + void _publishRoomRocketRegionBroadcastIfNeeded( + RoomRocketLaunchBroadcastMessage launch, + ) { + if (!launch.isValid || + !_shouldCurrentUserPublishRoomRocketRegionBroadcast(launch)) { + return; + } + final key = _roomRocketLaunchNoticeKey(launch); + if (key.isEmpty) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + _recentRoomRocketRegionBroadcastTimes.removeWhere( + (_, timestamp) => now - timestamp > _roomRocketRegionBroadcastRecentTtlMs, + ); + if (_recentRoomRocketRegionBroadcastTimes.containsKey(key)) { + return; + } + _recentRoomRocketRegionBroadcastTimes[key] = now; + unawaited(sendRoomRocketLaunchBroadcast(launch)); + } + + bool _shouldCurrentUserPublishRoomRocketRegionBroadcast( + RoomRocketLaunchBroadcastMessage launch, + ) { + final currentUser = AccountStorage().getCurrentUser()?.userProfile; + final currentKeys = { + currentUser?.id?.trim() ?? '', + currentUser?.account?.trim() ?? '', + }..removeWhere((item) => item.isEmpty); + final triggerKeys = { + launch.triggerUserId.trim(), + launch.triggerUser.userId.trim(), + launch.triggerUser.account.trim(), + }..removeWhere((item) => item.isEmpty); + if (triggerKeys.isNotEmpty) { + return currentKeys.any(triggerKeys.contains); + } + return true; + } + String _rocketIconUrlForLevel(int level) { final currentContext = context; if (currentContext == null || !currentContext.mounted) { @@ -2228,10 +2279,7 @@ class RealTimeMessagingManager extends ChangeNotifier { type: 3, roomId: launch.roomId, rocketLevel: launch.safeLevel, - userAvatarUrl: _firstNonBlank([ - launch.roomCoverUrl, - launch.triggerUser.avatar, - ]), + userAvatarUrl: launch.roomCoverUrl, userName: _firstNonBlank([launch.roomName, launch.roomAccount]), userId: _firstNonBlank([ launch.triggerUser.account, @@ -2334,6 +2382,18 @@ class RealTimeMessagingManager extends ChangeNotifier { roomProfile?.roomCover, _payloadAssetText(payload['roomCoverUrl'] ?? payload['room_cover_url']), _payloadAssetText(payload['roomCover'] ?? payload['room_cover']), + _payloadAssetText(payload['roomAvatar'] ?? payload['room_avatar']), + _payloadAssetText(payload['roomIcon'] ?? payload['room_icon']), + _payloadAssetText(payload['coverUrl'] ?? payload['cover_url']), + _payloadAssetText(payload['cover']), + _payloadAssetText(_broadcastPayloadMap(payload['room'])['coverUrl']), + _payloadAssetText(_broadcastPayloadMap(payload['room'])['cover']), + _payloadAssetText(_broadcastPayloadMap(payload['room'])['avatar']), + _payloadAssetText( + _broadcastPayloadMap(payload['roomInfo'])['coverUrl'], + ), + _payloadAssetText(_broadcastPayloadMap(payload['roomInfo'])['cover']), + _payloadAssetText(_broadcastPayloadMap(payload['roomInfo'])['avatar']), ]), roundNo: _payloadNum(payload['roundNo'] ?? payload['round_no'])?.toInt() ?? 0, @@ -3377,6 +3437,81 @@ class RealTimeMessagingManager extends ChangeNotifier { } } + Future sendRoomRocketLaunchBroadcast( + RoomRocketLaunchBroadcastMessage launch, + ) async { + if (!launch.isValid) { + return; + } + var regionGroupId = roomRedPacketBroadcastGroupId?.trim() ?? ''; + if (regionGroupId.isEmpty) { + await syncRoomRedPacketBroadcastGroup(); + regionGroupId = roomRedPacketBroadcastGroupId?.trim() ?? ''; + } + if (regionGroupId.isEmpty) { + return; + } + final message = { + 'type': SCRoomMsgType.voiceRoomRocketLaunchBroadcast, + 'data': _roomRocketLaunchBroadcastPayload(launch), + }; + await _sendRoomRocketLaunchCustomMessage( + regionGroupId, + jsonEncode(message), + ); + } + + Map _roomRocketLaunchBroadcastPayload( + RoomRocketLaunchBroadcastMessage launch, + ) { + final launchNo = _firstNonBlank([ + launch.launchNo, + _roomRocketLaunchNoticeKey(launch), + ]); + return { + 'roomId': launch.roomId, + 'roomAccount': launch.roomAccount, + 'roomName': launch.roomName, + 'roomCoverUrl': launch.roomCoverUrl, + 'roundNo': launch.roundNo, + 'level': launch.safeLevel, + 'rocketLevel': launch.safeLevel, + 'rocketIconUrl': launch.rocketIconUrl, + 'rocketAnimationUrl': launch.rocketAnimationUrl, + 'durationSeconds': launch.durationSeconds, + 'countdownSeconds': launch.durationSeconds, + 'triggerUserId': launch.triggerUserId, + 'triggerUser': { + 'userId': launch.triggerUser.userId, + 'nickname': launch.triggerUser.nickname, + 'avatar': launch.triggerUser.avatar, + 'account': launch.triggerUser.account, + 'countryCode': launch.triggerUser.countryCode, + 'countryName': launch.triggerUser.countryName, + }, + 'launchNo': launchNo, + }; + } + + Future _sendRoomRocketLaunchCustomMessage( + String groupId, + String data, + ) async { + try { + final textMsg = await TencentImSDKPlugin.v2TIMManager + .getMessageManager() + .createCustomMessage(data: data); + if (textMsg.code != 0 || textMsg.data?.id == null) { + return; + } + await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( + id: textMsg.data!.id!, + groupID: groupId, + receiver: '', + ); + } catch (_) {} + } + Future _sendRoomRedPacketCustomMessage( String groupId, String data, @@ -3453,6 +3588,7 @@ class RealTimeMessagingManager extends ChangeNotifier { _resetLuckGiftBurstQueue(); _recentRoomRocketLaunchTimes.clear(); _recentRoomRocketLaunchLooseTimes.clear(); + _recentRoomRocketRegionBroadcastTimes.clear(); _roomRocketRewardPopupRetryTimer?.cancel(); _roomRocketRewardPopupRetryTimer = null; onNewMessageListenerGroupMap.forEach((k, v) { diff --git a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart index dbdbd08..0b4a0e8 100644 --- a/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart +++ b/lib/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart @@ -167,6 +167,9 @@ String _roomCoverUrl(Map data) { _asMap(data['room'])['coverUrl'], _asMap(data['room'])['cover'], _asMap(data['room'])['avatar'], + _asMap(data['roomInfo'])['coverUrl'], + _asMap(data['roomInfo'])['cover'], + _asMap(data['roomInfo'])['avatar'], ]); } diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index 9c165fb..bb1da46 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -492,12 +492,29 @@ class OverlayManager { if (previousMessage != null) { visualTop += _stackedVisualHeightFor(previousMessage) + _stackedFloatingGap; + } else if (_shouldReserveStackedSlot(message, key)) { + visualTop += _stackedReservedVisualHeightFor(key) + _stackedFloatingGap; } } final wrapperTop = visualTop - _stackedInternalTopFor(message); return wrapperTop < 0 ? 0 : wrapperTop; } + bool _shouldReserveStackedSlot(SCFloatingMessage message, int slotKey) { + return _homeRootTabsVisible && + _isRegionRoomRocketMessage(message) && + slotKey == 4; + } + + double _stackedReservedVisualHeightFor(int slotKey) { + switch (slotKey) { + case 4: + return 66; + default: + return 0; + } + } + double _stackedVisualHeightFor(SCFloatingMessage message) { switch (message.type) { case 0: diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart index 3cd10a9..b5565fa 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart @@ -289,6 +289,8 @@ class RoomRocketApiMapper { return RoomRocketRewardWinner( nickname: _rewardWinnerName(entry.value, profile), avatarUrl: _rewardWinnerAvatar(entry.value, profile), + isTop1: _hasTop1Reward(entry.value), + isIgniter: _hasIgniteReward(entry.value), rewards: rewardItemsFromRecords(entry.value), ); }) @@ -335,6 +337,33 @@ class RoomRocketApiMapper { return null; } + static bool _hasTop1Reward(List records) { + return records.any((record) { + final scene = _normalizedRewardScene(record.rewardScene); + return scene.contains('TOP1') || + scene.contains('TOPONE') || + scene.contains('RANK1') || + scene.contains('RANKONE') || + scene.contains('FIRST') || + scene.contains('KING'); + }); + } + + static bool _hasIgniteReward(List records) { + return records.any((record) { + final scene = _normalizedRewardScene(record.rewardScene); + return scene.contains('IGNITE') || + scene.contains('SETOFF') || + scene.contains('LAUNCH') || + scene.contains('TRIGGER') || + scene.contains('LIGHT'); + }); + } + + static String _normalizedRewardScene(String scene) { + return scene.trim().toUpperCase().replaceAll(RegExp(r'[\s_\-]+'), ''); + } + static String _rewardWinnerKey(SCRoomRocketRewardRecordRes record) { final userId = record.userId.trim(); if (userId.isNotEmpty) { diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart index 86fa2af..1e67126 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog.dart @@ -192,6 +192,8 @@ class RoomRocketRewardWinner { this.avatarAsset, this.avatarUrl, this.frameAsset, + this.isTop1 = false, + this.isIgniter = false, this.rewards = const [], }); @@ -199,6 +201,8 @@ class RoomRocketRewardWinner { final String? avatarAsset; final String? avatarUrl; final String? frameAsset; + final bool isTop1; + final bool isIgniter; final List rewards; RoomRocketRewardWinner withRewards(List nextRewards) { @@ -207,6 +211,8 @@ class RoomRocketRewardWinner { avatarAsset: avatarAsset, avatarUrl: avatarUrl, frameAsset: frameAsset, + isTop1: isTop1, + isIgniter: isIgniter, rewards: nextRewards, ); } @@ -442,11 +448,7 @@ class _RewardDialogCanvas extends StatelessWidget { if (index >= winners.length) { return _LoadMoreRow(scale: scale); } - return _WinnerRow( - scale: scale, - winner: winners[index], - showRocketBadge: index == 0, - ); + return _WinnerRow(scale: scale, winner: winners[index]); }, ), ), @@ -595,15 +597,10 @@ class _RewardDetailCard extends StatelessWidget { } class _WinnerRow extends StatelessWidget { - const _WinnerRow({ - required this.scale, - required this.winner, - required this.showRocketBadge, - }); + const _WinnerRow({required this.scale, required this.winner}); final double scale; final RoomRocketRewardWinner winner; - final bool showRocketBadge; @override Widget build(BuildContext context) { @@ -626,21 +623,36 @@ class _WinnerRow extends StatelessWidget { height: 39 * scale, child: _WinnerAvatar(scale: scale, winner: winner), ), - if (showRocketBadge) + if (winner.isTop1) Positioned( - left: 40 * scale, - top: 26 * scale, + left: 17 * scale, + top: 39 * scale, + width: 39 * scale, + height: 9 * scale, + child: Text( + 'top1', + textScaler: TextScaler.noScaling, + maxLines: 1, + overflow: TextOverflow.visible, + textAlign: TextAlign.center, + style: TextStyle( + color: const Color(0xFF75F3CB), + fontSize: 7 * scale, + height: 1, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + if (winner.isIgniter) + Positioned( + left: 45 * scale, + top: -1 * scale, width: 14 * scale, height: 14 * scale, - child: ClipRRect( - borderRadius: BorderRadius.circular(3 * scale), - child: Transform.rotate( - angle: math.pi / 4, - child: Image.asset( - _RewardDialogAssets.rocketBody, - fit: BoxFit.cover, - ), - ), + child: Image.asset( + _RewardDialogAssets.rocketBody, + fit: BoxFit.contain, ), ), Positioned( diff --git a/test/room_rocket_status_test.dart b/test/room_rocket_status_test.dart index f6790f3..a336342 100644 --- a/test/room_rocket_status_test.dart +++ b/test/room_rocket_status_test.dart @@ -267,6 +267,32 @@ void main() { expect(winners.single.avatarUrl, 'https://example.com/avatar.png'); }); + test('marks rocket reward winners by reward scene', () { + final winners = RoomRocketApiMapper.winnersFromRecords([ + SCRoomRocketRewardRecordRes.fromJson({ + 'id': 'r1', + 'userId': 'top-user', + 'userNickname': 'top winner', + 'rewardScene': 'TOP1', + 'rewardType': 'GOLD', + 'rewardAmount': 100, + }), + SCRoomRocketRewardRecordRes.fromJson({ + 'id': 'r2', + 'userId': 'ignite-user', + 'userNickname': 'igniter', + 'rewardScene': 'SET_OFF', + 'rewardType': 'PROPS', + 'rewardAmount': 1, + }), + ]); + + expect(winners.first.isTop1, isTrue); + expect(winners.first.isIgniter, isFalse); + expect(winners.last.isTop1, isFalse); + expect(winners.last.isIgniter, isTrue); + }); + test('keeps nested rocket reward users separated by profile id', () { final winners = RoomRocketApiMapper.winnersFromRecords([ SCRoomRocketRewardRecordRes.fromJson({