From 44e10028b9b7c105b41cdcad6bd1c67530613ab4 Mon Sep 17 00:00:00 2001 From: roxy Date: Sat, 23 May 2026 15:33:14 +0800 Subject: [PATCH] buf fxi --- lib/modules/gift/gift_page.dart | 79 --- .../user/profile/person_detail_page.dart | 44 +- .../audio/room_rtc_engine_adapter.dart | 4 +- lib/services/audio/rtc_manager.dart | 566 +++++++++++++++--- lib/services/audio/rtm_manager.dart | 232 +++++-- .../audio/trtc_room_rtc_engine_adapter.dart | 9 +- lib/services/auth/user_profile_manager.dart | 97 ++- .../models/res/sc_room_rocket_api_res.dart | 358 ++++++++++- .../repositories/sc_room_repository_imp.dart | 15 +- .../tools/sc_cp_relation_pair_utils.dart | 18 + .../room/rocket/room_rocket_api_mapper.dart | 52 ++ .../room_rocket_reward_dialog_loader.dart | 73 ++- .../widgets/room/room_user_info_card.dart | 23 +- test/room_rocket_status_test.dart | 75 +++ test/room_seat_widget_test.dart | 43 ++ test/sc_cp_relation_pair_utils_test.dart | 17 + test/trtc_room_rtc_engine_adapter_test.dart | 24 + 17 files changed, 1453 insertions(+), 276 deletions(-) diff --git a/lib/modules/gift/gift_page.dart b/lib/modules/gift/gift_page.dart index acc4b09..caf02c0 100644 --- a/lib/modules/gift/gift_page.dart +++ b/lib/modules/gift/gift_page.dart @@ -21,7 +21,6 @@ import 'package:yumi/shared/data_sources/sources/remote/net/api.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart'; import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.dart'; -import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; import 'package:yumi/main.dart'; import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_room_msg_type.dart'; @@ -35,7 +34,6 @@ import 'package:yumi/services/audio/rtc_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/ui_kit/widgets/gift/sc_gift_combo_send_button.dart'; -import 'package:yumi/ui_kit/widgets/room/cp/room_cp_invite_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:yumi/modules/gift/gift_tab_page.dart'; import 'package:yumi/modules/gift/cp_rights/cp_rights_guide_page.dart'; @@ -1564,7 +1562,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { } await _refreshGiftBackpack(); _refreshCpStateAfterGift(request, profileManager); - _showCpGiftWaitingDialog(request); return; } @@ -1629,7 +1626,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { } profileManager?.updateBalance(result); _refreshCpStateAfterGift(request, profileManager); - _showCpGiftWaitingDialog(request); } catch (e) { final errorMessage = request.isBackpackGiftRequest && _isBackpackQuantityError(e) @@ -1672,81 +1668,6 @@ class _GiftPageState extends State with TickerProviderStateMixin { unawaited(profileManager?.refreshLoadedCpProfiles()); } - void _showCpGiftWaitingDialog(_GiftSendRequest request) { - if (!_isCpGift(request.gift)) { - return; - } - if (request.isSelfRecipientRequest) { - _giftFxLog( - 'skip local CP waiting dialog reason=self_recipient ' - 'giftId=${request.gift.id} ' - 'acceptUserIds=${request.acceptUserIds.join(",")}', - ); - return; - } - final context = navigatorKey.currentState?.context; - final currentProfile = AccountStorage().getCurrentUser()?.userProfile; - final receiverProfile = - request.acceptUsers.isNotEmpty ? request.acceptUsers.first.user : null; - if (context == null || currentProfile == null || receiverProfile == null) { - _giftFxLog( - 'skip local CP waiting dialog reason=missing_profile ' - 'giftId=${request.gift.id} ' - 'acceptUserIds=${request.acceptUserIds.join(",")}', - ); - return; - } - final relationType = scNormalizeCpRelationType(request.cpRelationType); - _giftFxLog( - 'show local CP waiting dialog ' - 'giftId=${request.gift.id} ' - 'relationType=$relationType ' - 'receiverId=${receiverProfile.id}', - ); - unawaited( - RoomCpInviteDialog.show( - context, - inviter: RoomCpInviteDialogUser( - name: _cpInviteDisplayName(currentProfile), - avatarUrl: currentProfile.userAvatar ?? "", - headdress: currentProfile.getHeaddress()?.sourceUrl ?? "", - headdressCover: currentProfile.getHeaddress()?.cover ?? "", - ), - receiver: RoomCpInviteDialogUser( - name: _cpInviteDisplayName(receiverProfile), - avatarUrl: receiverProfile.userAvatar ?? "", - headdress: receiverProfile.getHeaddress()?.sourceUrl ?? "", - headdressCover: receiverProfile.getHeaddress()?.cover ?? "", - ), - style: RoomCpInviteDialogStyle.waiting, - countdownSeconds: 86399, - descriptionText: _cpInviteWaitingDescriptionText(relationType), - ), - ); - } - - String _cpInviteDisplayName(SocialChatUserProfile profile) { - for (final value in [profile.userNickname, profile.account, profile.id]) { - final text = value?.trim() ?? ""; - if (text.isNotEmpty) { - return text; - } - } - return "User"; - } - - String _cpInviteWaitingDescriptionText(String relationType) { - switch (scNormalizeCpRelationType(relationType)) { - case "BROTHER": - return "We invite you to become Brother"; - case "SISTERS": - return "We invite you to become Sister"; - case "CP": - default: - return "We invite you to become Couple"; - } - } - Future> _giveBackpackGiftToRecipients( SCChatRoomRepository repository, _GiftSendRequest request, diff --git a/lib/modules/user/profile/person_detail_page.dart b/lib/modules/user/profile/person_detail_page.dart index bceb80d..7ab0ff1 100644 --- a/lib/modules/user/profile/person_detail_page.dart +++ b/lib/modules/user/profile/person_detail_page.dart @@ -1383,14 +1383,20 @@ class _PersonDetailPageState extends State { } List _profileCloseFriendRelations(SocialChatUserProfile? profile) { - return (profile?.closeFriendList ?? const []) - .where((relation) { - final type = _normalizeProfileRelationType(relation.relationType); - return (type == "BROTHER" || type == "SISTERS") && - _profileRelationHasPartner(profile, relation); - }) - .take(6) - .toList(); + final brothers = []; + final sisters = []; + for (final relation in profile?.closeFriendList ?? const []) { + if (!_profileRelationHasPartner(profile, relation)) { + continue; + } + final type = _normalizeProfileRelationType(relation.relationType); + if (type == "BROTHER" && brothers.length < 3) { + brothers.add(relation); + } else if (type == "SISTERS" && sisters.length < 3) { + sisters.add(relation); + } + } + return [...brothers, ...sisters]; } bool _profileRelationHasPartner( @@ -1611,6 +1617,14 @@ class _PersonDetailPageState extends State { if (!mounted) { return; } + Provider.of( + context, + listen: false, + )?.invalidateRoomMicRelationBetween( + userId: payload.leftUserId, + peerUserId: partnerUserId, + relationType: payload.relationType, + ); await Provider.of( context, listen: false, @@ -2589,6 +2603,20 @@ class _PersonDetailPageState extends State { relationType: cpRelation?.relationType ?? "CP", ) .then((result) { + if (mounted) { + Provider.of( + context, + listen: false, + )?.invalidateRoomMicRelationBetween( + userId: + AccountStorage() + .getCurrentUser() + ?.userProfile + ?.id, + peerUserId: ref.userProfile?.id, + relationType: cpRelation?.relationType ?? "CP", + ); + } ref.refreshLoadedCpProfiles(); SmartDialog.dismiss(tag: "showPartWaysDialog"); SCTts.show( diff --git a/lib/services/audio/room_rtc_engine_adapter.dart b/lib/services/audio/room_rtc_engine_adapter.dart index 7115656..7c28a79 100644 --- a/lib/services/audio/room_rtc_engine_adapter.dart +++ b/lib/services/audio/room_rtc_engine_adapter.dart @@ -20,9 +20,11 @@ class RoomRtcEnterRoomConfig { } class RoomRtcAnchorConfig { - const RoomRtcAnchorConfig({required this.muted}); + const RoomRtcAnchorConfig({required this.muted, bool? publishMuted}) + : publishMuted = publishMuted ?? muted; final bool muted; + final bool publishMuted; } class RoomRtcAudioMixingConfig { diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index cb70688..c74cccc 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -10,7 +10,6 @@ import 'package:yumi/shared/tools/sc_permission_utils.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; import 'package:yumi/shared/tools/sc_room_top_layer_guard.dart'; import 'package:yumi/shared/tools/sc_cp_relation_notice_utils.dart'; -import 'package:yumi/shared/tools/sc_room_rocket_reward_popup_ack.dart'; import 'package:yumi/shared/data_sources/models/message/room_rocket_launch_broadcast_message.dart'; import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart'; @@ -63,7 +62,6 @@ import '../../ui_kit/components/sc_float_ichart.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_asset_preloader.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; import '../../ui_kit/widgets/room/rocket/room_rocket_api_mapper.dart'; -import '../../ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart'; typedef OnSoundVoiceChange = Function(num index, int volum); typedef RtcProvider = RealTimeCommunicationManager; @@ -321,8 +319,21 @@ class _RoomMicRelationProfileCache { bool isExpired(DateTime now, Duration ttl) => now.difference(loadedAt) > ttl; } +class _RecentMicChangeSeatSnapshot { + const _RecentMicChangeSeatSnapshot({ + required this.mic, + required this.expiresAtMs, + }); + + final MicRes mic; + final int expiresAtMs; + + String get userId => (mic.user?.id ?? "").trim(); + + bool isExpired(int nowMs) => expiresAtMs <= nowMs; +} + class RealTimeCommunicationManager extends ChangeNotifier { - static const String _roomRocketRewardDialogTag = 'showRoomRocketRewardDialog'; static const String _roomRocketPagLaunchDialogTag = 'showRoomRocketPagLaunchDialog'; static const List _roomRocketGiftRefreshDelays = [ @@ -348,10 +359,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { 'SISTERS', ]; static const Duration _roomMicRelationCacheTtl = Duration(minutes: 5); + static const Duration _roomMicRelationDismissedPairTtl = Duration(minutes: 5); static const int _roomPasswordNotTrueErrorCode = 9005; static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); + static const Duration _micChangeSnapshotGracePeriod = Duration(seconds: 4); static const int _exitNetworkRetryLimit = 3; static const Duration _exitNetworkRetryDelay = Duration(milliseconds: 500); static const Duration _selfMicStateGracePeriod = Duration(seconds: 4); @@ -412,6 +425,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool _pendingMicListRefreshNotifyIfUnchanged = false; Timer? _deferredMicListRefreshTimer; int _lastMicListRefreshStartedAtMs = 0; + final Map _recentMicChangeSeatSnapshots = + {}; num? _preferredSelfMicIndex; num? _pendingSelfMicSourceIndex; int? _pendingSelfMicSwitchGuardUntilMs; @@ -439,6 +454,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { final Map _roomMicRelationCache = {}; final Set _roomMicRelationLoadingUserIds = {}; + final Map _roomMicRelationDismissedPairUntil = + {}; final Set _roomMusicPublishingUserIds = {}; final Map _roomMusicPublishingClearTimers = {}; @@ -490,6 +507,18 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomMusicPublishingUserIds.contains(normalizedUserId); } + bool get _currentUserPublishingRoomMusic { + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + return isUserPublishingRoomMusic(currentUserId); + } + + bool _isCurrentUserId(String userId) { + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + return currentUserId.isNotEmpty && currentUserId == userId.trim(); + } + ///房间红包列表 List redPacketList = []; @@ -574,6 +603,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { final roomId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); if (roomId.isNotEmpty) { _scheduleRoomRocketEntryReadyFallback(roomId); + if (_roomStartupStatus == RoomStartupStatus.ready) { + _loadRoomRocketRewardPopups(roomId); + } } } notifyListeners(); @@ -772,6 +804,16 @@ class RealTimeCommunicationManager extends ChangeNotifier { normalizedMessage.contains('the path was not found'); } + bool _isInvalidKickOffMicPrecheckError(Object error) { + final normalizedMessage = _errorMessageFrom(error).toLowerCase(); + return (normalizedMessage.contains('parameter') && + (normalizedMessage.contains('incorrect') || + normalizedMessage.contains('incomplete'))) || + (normalizedMessage.contains('参数') && + (normalizedMessage.contains('错误') || + normalizedMessage.contains('不完整'))); + } + String _errorMessageFrom(Object error) { if (error is DioException) { final responseData = error.response?.data; @@ -1037,6 +1079,144 @@ class RealTimeCommunicationManager extends ChangeNotifier { return _stabilizeSelfMicSnapshot(completedMap, previousMap: previousMap); } + void _rememberMicChangeSnapshot(List mics) { + if (mics.isEmpty) { + return; + } + final nowMs = DateTime.now().millisecondsSinceEpoch; + _pruneRecentMicChangeSeatSnapshots(nowMs); + final expiresAtMs = nowMs + _micChangeSnapshotGracePeriod.inMilliseconds; + for (final mic in mics) { + final micIndex = mic.micIndex; + if (micIndex == null) { + continue; + } + _recentMicChangeSeatSnapshots[micIndex] = _RecentMicChangeSeatSnapshot( + mic: _mergeKnownVipIntoMic(mic).copyWith(micIndex: micIndex), + expiresAtMs: expiresAtMs, + ); + } + } + + void _pruneRecentMicChangeSeatSnapshots([int? nowMs]) { + if (_recentMicChangeSeatSnapshots.isEmpty) { + return; + } + final resolvedNowMs = nowMs ?? DateTime.now().millisecondsSinceEpoch; + _recentMicChangeSeatSnapshots.removeWhere( + (_, snapshot) => snapshot.isExpired(resolvedNowMs), + ); + } + + Map _stabilizeMicListSnapshotWithRecentMicChange( + Map nextMap, + ) { + final nowMs = DateTime.now().millisecondsSinceEpoch; + _pruneRecentMicChangeSeatSnapshots(nowMs); + if (_recentMicChangeSeatSnapshots.isEmpty) { + return nextMap; + } + + Map? stabilizedMap; + final confirmedSeatIndexes = []; + for (final entry in _recentMicChangeSeatSnapshots.entries) { + final seatIndex = entry.key; + final recentMic = entry.value.mic; + final recentUserId = entry.value.userId; + final currentMap = stabilizedMap ?? nextMap; + final incomingMic = currentMap[seatIndex]; + final incomingUserId = (incomingMic?.user?.id ?? "").trim(); + + if (recentUserId.isEmpty) { + if (incomingUserId.isEmpty) { + confirmedSeatIndexes.add(seatIndex); + continue; + } + stabilizedMap ??= Map.from(nextMap); + stabilizedMap[seatIndex] = _micChangeAuthoritativeSeat( + incomingMic: incomingMic, + recentMic: recentMic, + seatIndex: seatIndex, + clearUser: true, + ); + continue; + } + + if (incomingUserId == recentUserId) { + confirmedSeatIndexes.add(seatIndex); + continue; + } + + stabilizedMap ??= Map.from(nextMap); + for (final otherEntry in currentMap.entries) { + if (otherEntry.key == seatIndex) { + continue; + } + if ((otherEntry.value.user?.id ?? "").trim() != recentUserId) { + continue; + } + stabilizedMap[otherEntry.key] = otherEntry.value.copyWith( + clearUser: true, + ); + } + stabilizedMap[seatIndex] = _micChangeAuthoritativeSeat( + incomingMic: incomingMic, + recentMic: recentMic, + seatIndex: seatIndex, + ); + } + + for (final seatIndex in confirmedSeatIndexes) { + _recentMicChangeSeatSnapshots.remove(seatIndex); + } + return stabilizedMap ?? nextMap; + } + + MicRes _micChangeAuthoritativeSeat({ + required MicRes? incomingMic, + required MicRes recentMic, + required num seatIndex, + bool clearUser = false, + }) { + final baseMic = + incomingMic ?? + MicRes( + roomId: currenRoom?.roomProfile?.roomProfile?.id, + micIndex: seatIndex, + ); + return baseMic.copyWith( + roomId: recentMic.roomId, + micIndex: recentMic.micIndex ?? seatIndex, + micLock: recentMic.micLock, + micMute: recentMic.micMute, + user: recentMic.user, + clearUser: clearUser, + roomToken: recentMic.roomToken, + emojiPath: recentMic.emojiPath, + type: recentMic.type, + number: recentMic.number, + ); + } + + void _applyMicListSnapshot( + List roomWheatList, { + bool notifyIfUnchanged = true, + }) { + final nextMap = _buildMicMap(roomWheatList); + final stabilizedNextMap = _stabilizeMicListSnapshotWithRecentMicChange( + nextMap, + ); + _applyMicSnapshot(stabilizedNextMap, notifyIfUnchanged: notifyIfUnchanged); + } + + @visibleForTesting + void debugApplyMicListSnapshot( + List roomWheatList, { + bool notifyIfUnchanged = true, + }) { + _applyMicListSnapshot(roomWheatList, notifyIfUnchanged: notifyIfUnchanged); + } + void _startSelfMicSwitchGuard({ required num sourceIndex, required num targetIndex, @@ -1417,16 +1597,26 @@ class RealTimeCommunicationManager extends ChangeNotifier { } final shouldPublishLocalAudio = role == _RoomRtcLocalAudioRole.anchor; - final recordingSignalVolume = shouldPublishLocalAudio && !muted ? 100 : 0; + final keepAudioPublishing = + shouldPublishLocalAudio && muted && _currentUserPublishingRoomMusic; + final localAudioMuted = + !shouldPublishLocalAudio || (muted && !keepAudioPublishing); + final recordingSignalMuted = !shouldPublishLocalAudio || muted; + final recordingSignalVolume = recordingSignalMuted ? 0 : 100; if (_lastAppliedLocalAudioRole == role && - _lastAppliedLocalAudioMuted == !shouldPublishLocalAudio && - _lastAppliedRecordingSignalMuted == !shouldPublishLocalAudio && + _lastAppliedLocalAudioMuted == localAudioMuted && + _lastAppliedRecordingSignalMuted == recordingSignalMuted && _lastAppliedRecordingSignalVolume == recordingSignalVolume) { return; } if (shouldPublishLocalAudio) { - unawaited(_switchRoomRtcToBroadcaster(muted: muted)); + unawaited( + _switchRoomRtcToBroadcaster( + muted: muted, + keepAudioPublishing: keepAudioPublishing, + ), + ); } else { unawaited(_switchRoomRtcToAudience()); } @@ -1449,8 +1639,13 @@ class RealTimeCommunicationManager extends ChangeNotifier { Map nextMap, { bool notifyIfUnchanged = true, }) { - final changed = !_sameMicMaps(roomWheatMap, nextMap); - roomWheatMap = nextMap; + final sanitizedNextMap = _withoutLocallyDismissedRoomMicRelationsFromMicMap( + nextMap, + ); + final changed = + !identical(sanitizedNextMap, nextMap) || + !_sameMicMaps(roomWheatMap, sanitizedNextMap); + roomWheatMap = sanitizedNextMap; final relationCacheChanged = _mergeCachedRoomMicRelationProfiles(); _scheduleRoomMicRelationHydration(); _syncSelfMicRuntimeState(); @@ -1463,6 +1658,58 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } + void invalidateRoomMicRelationBetween({ + required String? userId, + required String? peerUserId, + String? relationType, + }) { + final currentUserId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + final leftUserId = + (userId ?? "").trim().isNotEmpty ? userId!.trim() : currentUserId; + final rightUserId = (peerUserId ?? "").trim(); + if (leftUserId.isEmpty || + rightUserId.isEmpty || + leftUserId == rightUserId) { + return; + } + + final now = DateTime.now(); + _pruneRoomMicRelationDismissedPairs(now); + final normalizedType = scNormalizeCpRelationType(relationType); + final dismissedUntil = now.add(_roomMicRelationDismissedPairTtl); + final leftKeys = _roomMicRelationIdentityKeysForUserId(leftUserId); + final rightKeys = _roomMicRelationIdentityKeysForUserId(rightUserId); + for (final leftKey in leftKeys) { + for (final rightKey in rightKeys) { + _roomMicRelationDismissedPairUntil[_roomMicRelationDismissedPairKey( + leftKey, + rightKey, + normalizedType, + )] = + dismissedUntil; + } + } + + final dismissedUserIds = {leftUserId, rightUserId}; + _roomMicRelationCache.removeWhere( + (cacheUserId, _) => dismissedUserIds.contains(cacheUserId), + ); + _roomMicRelationLoadingUserIds.removeWhere(dismissedUserIds.contains); + + final nextMap = _withoutLocallyDismissedRoomMicRelationsFromMicMap( + roomWheatMap, + ); + if (!identical(nextMap, roomWheatMap)) { + roomWheatMap = nextMap; + notifyListeners(); + } + requestMicrophoneListRefresh( + notifyIfUnchanged: false, + minInterval: const Duration(milliseconds: 350), + ); + } + bool _mergeCachedRoomMicRelationProfiles() { final now = DateTime.now(); _roomMicRelationCache.removeWhere( @@ -1561,6 +1808,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { levelText: pair.levelText ?? _roomMicRelationLevelTextFromCabin(cabin), ); + if (_isRoomMicRelationLocallyDismissed(enrichedPair)) { + return; + } if (normalizedType == "CP") { cpPairs.add(enrichedPair); } else { @@ -1711,6 +1961,126 @@ class RealTimeCommunicationManager extends ChangeNotifier { return user?.id?.trim() ?? ""; } + Map _withoutLocallyDismissedRoomMicRelationsFromMicMap( + Map sourceMap, + ) { + if (_roomMicRelationDismissedPairUntil.isEmpty || sourceMap.isEmpty) { + return sourceMap; + } + _pruneRoomMicRelationDismissedPairs(DateTime.now()); + if (_roomMicRelationDismissedPairUntil.isEmpty) { + return sourceMap; + } + var changed = false; + final nextMap = sourceMap.map((index, mic) { + final user = mic.user; + if (user == null) { + return MapEntry(index, mic); + } + final nextUser = _withoutLocallyDismissedRoomMicRelations(user); + if (identical(nextUser, user)) { + return MapEntry(index, mic); + } + changed = true; + return MapEntry(index, mic.copyWith(user: nextUser)); + }); + return changed ? nextMap : sourceMap; + } + + SocialChatUserProfile _withoutLocallyDismissedRoomMicRelations( + SocialChatUserProfile user, + ) { + final nextCpList = _withoutLocallyDismissedRoomMicRelationList(user.cpList); + final nextCloseFriendList = _withoutLocallyDismissedRoomMicRelationList( + user.closeFriendList, + ); + if (identical(nextCpList, user.cpList) && + identical(nextCloseFriendList, user.closeFriendList)) { + return user; + } + final effectiveCpList = nextCpList ?? user.cpList ?? const []; + return user.copyWith( + cpList: nextCpList, + closeFriendList: nextCloseFriendList, + isCpRelation: effectiveCpList.isNotEmpty, + ); + } + + List? _withoutLocallyDismissedRoomMicRelationList( + List? sourceList, + ) { + if (sourceList == null || sourceList.isEmpty) { + return sourceList; + } + final filtered = sourceList + .where((relation) => !_isRoomMicRelationLocallyDismissed(relation)) + .toList(growable: false); + return filtered.length == sourceList.length ? sourceList : filtered; + } + + bool _isRoomMicRelationLocallyDismissed(CPRes relation) { + if (_roomMicRelationDismissedPairUntil.isEmpty) { + return false; + } + _pruneRoomMicRelationDismissedPairs(DateTime.now()); + if (_roomMicRelationDismissedPairUntil.isEmpty) { + return false; + } + final keys = _roomMicRelationDismissedPairKeysFromRelation(relation); + return keys.any(_roomMicRelationDismissedPairUntil.containsKey); + } + + Set _roomMicRelationDismissedPairKeysFromRelation(CPRes relation) { + final leftKeys = { + relation.meUserId?.trim() ?? "", + relation.meAccount?.trim() ?? "", + }..remove(""); + final rightKeys = { + relation.cpUserId?.trim() ?? "", + relation.cpAccount?.trim() ?? "", + }..remove(""); + if (leftKeys.isEmpty || rightKeys.isEmpty) { + return const {}; + } + final relationType = scNormalizeCpRelationType(relation.relationType); + return { + for (final leftKey in leftKeys) + for (final rightKey in rightKeys) + _roomMicRelationDismissedPairKey(leftKey, rightKey, relationType), + }; + } + + String _roomMicRelationDismissedPairKey( + String leftIdentity, + String rightIdentity, + String relationType, + ) { + final identities = [leftIdentity.trim(), rightIdentity.trim()] + ..sort(); + return '${scNormalizeCpRelationType(relationType)}|' + '${identities[0]}|${identities[1]}'; + } + + Set _roomMicRelationIdentityKeysForUserId(String userId) { + final normalizedUserId = userId.trim(); + final keys = {normalizedUserId}; + for (final mic in roomWheatMap.values) { + final user = mic.user; + if ((user?.id ?? "").trim() == normalizedUserId) { + keys + ..add(user?.id?.trim() ?? "") + ..add(user?.account?.trim() ?? ""); + } + } + return keys..remove(""); + } + + void _pruneRoomMicRelationDismissedPairs(DateTime now) { + _roomMicRelationDismissedPairUntil.removeWhere( + (_, expiresAt) => !expiresAt.isAfter(now), + ); + } + bool _pruneRoomMusicPublishingUsersToMicSeats() { if (_roomMusicPublishingUserIds.isEmpty) { return false; @@ -1797,6 +2167,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (clearMicSeats) { _roomMicRelationCache.clear(); _roomMicRelationLoadingUserIds.clear(); + _roomMicRelationDismissedPairUntil.clear(); roomWheatMap.clear(); notifyListeners(); } @@ -1935,13 +2306,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } - Future _switchRoomRtcToBroadcaster({required bool muted}) async { + Future _switchRoomRtcToBroadcaster({ + required bool muted, + bool keepAudioPublishing = false, + }) async { if (!_roomRtcJoined || _roomRtcEngineAdapter == null) { return false; } try { + final localAudioMuted = muted && !keepAudioPublishing; final switched = await _currentRoomRtcEngineAdapter.switchToAnchor( - RoomRtcAnchorConfig(muted: muted), + RoomRtcAnchorConfig(muted: muted, publishMuted: localAudioMuted), ); if (!switched) { _roomRtcRoleIsBroadcaster = false; @@ -1949,8 +2324,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { return false; } _lastAppliedLocalAudioRole = _RoomRtcLocalAudioRole.anchor; - _lastAppliedLocalAudioMuted = false; - _lastAppliedRecordingSignalMuted = false; + _lastAppliedLocalAudioMuted = localAudioMuted; + _lastAppliedRecordingSignalMuted = muted; _lastAppliedRecordingSignalVolume = muted ? 0 : 100; _roomRtcRoleIsBroadcaster = true; _resyncHeartbeatFromRoomRtcState(); @@ -1965,6 +2340,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { Future _rejoinCurrentRoomRtcForSameRoomReuse(String roomId) async { if (_hasCurrentRoomRtcConnection) { _setRoomStartupReady(); + if (_voiceRoomRouteVisible) { + _loadRoomRocketRewardPopups(roomId); + } return; } @@ -1989,6 +2367,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { return; } _setRoomStartupReady(); + if (_voiceRoomRouteVisible) { + _loadRoomRocketRewardPopups(roomId); + } } catch (error) { if (!_isCurrentRoomId(roomId)) { return; @@ -2030,7 +2411,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { final seatMuted = seat?.micMute ?? false; if (seatMuted || muted) { if (_roomRtcJoined && isOnMai()) { - await _switchRoomRtcToBroadcaster(muted: true); + await _switchRoomRtcToBroadcaster( + muted: true, + keepAudioPublishing: _currentUserPublishingRoomMusic, + ); } } else { await _switchRoomRtcToBroadcaster(muted: false); @@ -3516,58 +3900,13 @@ class RealTimeCommunicationManager extends ChangeNotifier { } void _loadRoomRocketRewardPopups(String roomId) { - if (roomId.trim().isEmpty) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty || + normalizedRoomId != + (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim()) { return; } - SCChatRoomRepository() - .roomRocketRewardPopups(roomId) - .then((res) { - final popupRecords = SCRoomRocketRewardPopupAck.filterUnshownRecords( - res.records, - ); - if (popupRecords.isEmpty || - roomId != - (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim()) { - return; - } - if (SCRoomTopLayerGuard().isActive) { - SCRoomTopLayerGuard().runWhenInactive(() { - _loadRoomRocketRewardPopups(roomId); - }); - return; - } - SCRoomRocketRewardPopupAck.markShownAndAck(popupRecords); - final recordIds = SCRoomRocketRewardPopupAck.recordIds(popupRecords); - SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); - SmartDialog.show( - tag: _roomRocketRewardDialogTag, - alignment: Alignment.center, - debounce: true, - animationType: SmartAnimationType.fade, - maskColor: Colors.transparent, - clickMaskDismiss: false, - builder: - (_) => SCRoomTopLayerSuppressor( - reason: 'room_rocket_reward_dialog', - keepDialogTags: const {_roomRocketRewardDialogTag}, - child: RoomRocketRewardDialogLoader( - roomId: roomId, - initialPopupRecords: popupRecords, - userProfileResolver: roomRocketRewardUserProfileForRecord, - onClose: () { - SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); - scheduleRoomRocketPostLaunchStatusRefresh(roomId: roomId); - if (recordIds.isNotEmpty) { - SCRoomRocketRewardPopupAck.markShownAndAck( - popupRecords, - ); - } - }, - ), - ), - ); - }) - .catchError((_) {}); + rtmProvider?.queryVoiceRoomRocketRewardPopups(normalizedRoomId); } ///更新房间火箭信息 @@ -4086,8 +4425,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (roomId != currenRoom?.roomProfile?.roomProfile?.id) { return; } - final nextMap = _buildMicMap(roomWheatList); - _applyMicSnapshot(nextMap, notifyIfUnchanged: notifyIfUnchanged); + _applyMicListSnapshot( + roomWheatList, + notifyIfUnchanged: notifyIfUnchanged, + ); } catch (error) { if (_isMissingPathError(error)) { _disableMicListRefreshForCurrentSession = true; @@ -4539,8 +4880,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { _isHandlingRoomRtcFailure = false; _pendingMicListRefresh = false; _pendingMicListRefreshNotifyIfUnchanged = false; + _recentMicChangeSeatSnapshots.clear(); _seatEmojiEventVersions.clear(); _emojiPlaybackEventVersion = 0; + _roomMicRelationCache.clear(); + _roomMicRelationLoadingUserIds.clear(); + _roomMicRelationDismissedPairUntil.clear(); _clearRoomMusicPublishingStates(); roomRocketStatus = null; rtmProvider @@ -4788,6 +5133,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { final targetIndex = micGoUpRes.micIndex ?? index; final switched = await _switchRoomRtcToBroadcaster( muted: (micGoUpRes.micMute ?? false) || isMic, + keepAudioPublishing: + ((micGoUpRes.micMute ?? false) || isMic) && + _currentUserPublishingRoomMusic, ); if (!switched) { await _switchRoomRtcToAudience(); @@ -4912,34 +5260,52 @@ class RealTimeCommunicationManager extends ChangeNotifier { } ///踢人下麦 - killXiaMai(String userId) { + Future killXiaMai(String userId) async { try { - roomWheatMap.forEach((k, v) async { - if (v.user?.id == userId) { - var canKill = await SCChatRoomRepository().kickOffMicrophone( - currenRoom?.roomProfile?.roomProfile?.id ?? "", - userId, - ); - if (canKill) { - await SCChatRoomRepository().micKill( - currenRoom?.roomProfile?.roomProfile?.id ?? "", - k, - ); - Provider.of(context!, listen: false).dispatchMessage( - Msg( - groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount, - msg: userId, - type: SCRoomMsgType.killXiaMai, - ), - addLocal: false, - ); - roomWheatMap[k]?.setUser = null; - notifyListeners(); - } else { - SCTts.show("cant kill the user"); - } + final targetUserId = userId.trim(); + final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; + if (targetUserId.isEmpty || roomId.isEmpty) { + SCTts.show("kill the user fail"); + return; + } + num? targetMicIndex; + for (final entry in roomWheatMap.entries) { + if ((entry.value.user?.id ?? "").trim() == targetUserId) { + targetMicIndex = entry.key; + break; } - }); + } + if (targetMicIndex == null) { + SCTts.show("cant kill the user"); + return; + } + var canKill = true; + try { + canKill = await SCChatRoomRepository().kickOffMicrophone( + roomId, + targetUserId, + mickIndex: targetMicIndex, + ); + } catch (error) { + if (!_isInvalidKickOffMicPrecheckError(error)) { + rethrow; + } + } + if (canKill) { + await SCChatRoomRepository().micKill(roomId, targetMicIndex); + Provider.of(context!, listen: false).dispatchMessage( + Msg( + groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount, + msg: targetUserId, + type: SCRoomMsgType.killXiaMai, + ), + addLocal: false, + ); + roomWheatMap[targetMicIndex]?.setUser = null; + notifyListeners(); + } else { + SCTts.show("cant kill the user"); + } } catch (e) { SCTts.show("kill the user fail"); } @@ -5024,7 +5390,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (mics == null || mics.isEmpty) { return; } - _applyMicSnapshot(_buildMicMap(mics)); + final nextMap = _buildMicMap(mics); + _rememberMicChangeSnapshot(mics); + _applyMicSnapshot(nextMap); } ///找一个空麦位 -1表示没有空位 @@ -5142,7 +5510,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { true, ); if (isOnMaiInIndex(index)) { - await _switchRoomRtcToBroadcaster(muted: true); + await _switchRoomRtcToBroadcaster( + muted: true, + keepAudioPublishing: _currentUserPublishingRoomMusic, + ); } var mic = roomWheatMap[index]; if (mic != null) { @@ -5316,6 +5687,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { () { _roomMusicPublishingClearTimers.remove(normalizedUserId); if (_roomMusicPublishingUserIds.remove(normalizedUserId)) { + if (_isCurrentUserId(normalizedUserId)) { + _syncSelfMicRuntimeState(); + } notifyListeners(); } }, @@ -5324,6 +5698,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomMusicPublishingClearTimers.remove(normalizedUserId)?.cancel(); changed = _roomMusicPublishingUserIds.remove(normalizedUserId); } + if (changed && _isCurrentUserId(normalizedUserId)) { + _syncSelfMicRuntimeState(); + } if (changed && notifyIfChanged) { notifyListeners(); } @@ -5456,6 +5833,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } return _switchRoomRtcToBroadcaster( muted: (currentUserMic.micMute ?? false) || isMic, + keepAudioPublishing: true, ); } } diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 300dd73..60a3ef6 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -148,8 +148,8 @@ class RealTimeMessagingManager extends ChangeNotifier { static const int _roomRocketLaunchRecentTtlMs = 90000; static const int _roomRocketLaunchLooseRecentTtlMs = 12000; static const int _roomRocketRegionBroadcastRecentTtlMs = 90000; - static const int _roomRocketRewardPendingTtlMs = 120000; - static const int _roomRocketRewardProbeTtlMs = 120000; + static const int _roomRocketRewardPendingTtlMs = 3600000; + static const int _roomRocketRewardProbeTtlMs = 3600000; static const int _roomRocketLaunchCountdownRetainMs = 10000; static const int _roomRocketLaunchEffectConsumedTtlMs = 120000; static const Duration _createRoomGroupTimeout = Duration(seconds: 12); @@ -294,6 +294,7 @@ class RealTimeMessagingManager extends ChangeNotifier { final Set _hiddenCurrentRoomRocketLaunchKeys = {}; final Map _roomRocketRewardHoldUntilTimes = {}; final Map _roomRocketRewardHoldTimers = {}; + final Set _roomRocketRewardPopupLoadingRoomIds = {}; Timer? _roomRocketRewardPopupRetryTimer; Timer? _roomRocketRoomReadyRewardRetryTimer; final Set _shownCpInviteMessageKeys = {}; @@ -3929,7 +3930,15 @@ class RealTimeMessagingManager extends ChangeNotifier { .toSet(); } - void handleVoiceRoomReadyForRocketRewards(String roomId, {int attempt = 0}) { + void queryVoiceRoomRocketRewardPopups(String roomId) { + handleVoiceRoomReadyForRocketRewards(roomId, forceQuery: true); + } + + void handleVoiceRoomReadyForRocketRewards( + String roomId, { + int attempt = 0, + bool forceQuery = false, + }) { final normalizedRoomId = roomId.trim(); if (normalizedRoomId.isEmpty) { return; @@ -3941,11 +3950,15 @@ class RealTimeMessagingManager extends ChangeNotifier { _recentRoomRocketRewardProbeTimes[normalizedRoomId] != null; final hasLaunch = _pendingRoomRocketCountdownLaunches[normalizedRoomId] != null; - if (pending == null && !hasProbe && !hasLaunch) { + if (pending == null && !hasProbe && !hasLaunch && !forceQuery) { return; } if (!_isCurrentVisibleVoiceRoom(normalizedRoomId)) { - _scheduleRoomRocketReadyRewardRetry(normalizedRoomId, attempt); + _scheduleRoomRocketReadyRewardRetry( + normalizedRoomId, + attempt, + forceQuery: forceQuery, + ); return; } final resumedLaunch = _resumeRoomRocketLaunchForCurrentRoom( @@ -3954,11 +3967,18 @@ class RealTimeMessagingManager extends ChangeNotifier { final pendingAfterResume = _pendingRoomRocketRewardPopups[normalizedRoomId]; final hasProbeAfterResume = _recentRoomRocketRewardProbeTimes[normalizedRoomId] != null; - if (pendingAfterResume == null && !hasProbeAfterResume) { + if (pendingAfterResume == null && !hasProbeAfterResume && !forceQuery) { return; } - if (!resumedLaunch && pendingAfterResume == null && attempt == 0) { - _scheduleRoomRocketReadyRewardRetry(normalizedRoomId, attempt); + if (!resumedLaunch && + pendingAfterResume == null && + attempt == 0 && + !forceQuery) { + _scheduleRoomRocketReadyRewardRetry( + normalizedRoomId, + attempt, + forceQuery: forceQuery, + ); return; } _pendingRoomRocketRewardPopups.remove(normalizedRoomId); @@ -3984,7 +4004,11 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } - void _scheduleRoomRocketReadyRewardRetry(String roomId, int attempt) { + void _scheduleRoomRocketReadyRewardRetry( + String roomId, + int attempt, { + bool forceQuery = false, + }) { if (attempt >= _roomRocketRoomReadyRewardRetryDelays.length) { return; } @@ -3993,7 +4017,11 @@ class RealTimeMessagingManager extends ChangeNotifier { _roomRocketRoomReadyRewardRetryDelays[attempt], () { _roomRocketRoomReadyRewardRetryTimer = null; - handleVoiceRoomReadyForRocketRewards(roomId, attempt: attempt + 1); + handleVoiceRoomReadyForRocketRewards( + roomId, + attempt: attempt + 1, + forceQuery: forceQuery, + ); }, ); } @@ -4010,11 +4038,12 @@ class RealTimeMessagingManager extends ChangeNotifier { final now = DateTime.now().millisecondsSinceEpoch; _pruneRoomRocketRewardFallbacks(now); final existing = _pendingRoomRocketRewardPopups[normalizedRoomId]; + final existingRecords = + existing?.initialRoomRecords ?? const []; final records = initialRoomRecords.isNotEmpty - ? List.unmodifiable(initialRoomRecords) - : existing?.initialRoomRecords ?? - const []; + ? _mergeRoomRocketRewardRecords(existingRecords, initialRoomRecords) + : existingRecords; _pendingRoomRocketRewardPopups[normalizedRoomId] = _PendingRoomRocketRewardPopup( roomId: normalizedRoomId, @@ -4023,6 +4052,47 @@ class RealTimeMessagingManager extends ChangeNotifier { ); } + List _mergeRoomRocketRewardRecords( + List existingRecords, + List incomingRecords, + ) { + if (existingRecords.isEmpty) { + return List.unmodifiable(incomingRecords); + } + if (incomingRecords.isEmpty) { + return existingRecords; + } + final merged = [...existingRecords]; + final seen = existingRecords.map(_roomRocketRewardRecordCacheKey).toSet(); + for (final record in incomingRecords) { + if (seen.add(_roomRocketRewardRecordCacheKey(record))) { + merged.add(record); + } + } + return List.unmodifiable(merged); + } + + String _roomRocketRewardRecordCacheKey(SCRoomRocketRewardRecordRes record) { + final id = record.id.trim(); + if (id.isNotEmpty) { + return 'id:$id'; + } + return [ + record.launchNo.trim(), + record.roomId.trim(), + record.roundNo.toString(), + record.level.toString(), + record.userId.trim(), + record.rewardScene.trim().toUpperCase(), + record.rewardType.trim().toUpperCase(), + record.rewardItemId.trim(), + record.rewardName.trim(), + record.rewardAmount.toString(), + record.grantTime.trim(), + record.createTime.trim(), + ].join('|'); + } + void _markRoomRocketRewardProbe(String roomId) { final normalizedRoomId = roomId.trim(); if (normalizedRoomId.isEmpty) { @@ -4532,12 +4602,14 @@ class RealTimeMessagingManager extends ChangeNotifier { List initialRoomRecords = const [], }) async { - if (!_isCurrentVisibleVoiceRoom(roomId)) { + final normalizedRoomId = roomId.trim(); + if (normalizedRoomId.isEmpty || + !_isCurrentVisibleVoiceRoom(normalizedRoomId)) { return; } - if (_isRoomRocketRewardPopupHeld(roomId)) { + if (_isRoomRocketRewardPopupHeld(normalizedRoomId)) { _scheduleRoomRocketRewardPopupAfterHold( - roomId, + normalizedRoomId, initialRoomRecords: initialRoomRecords, ); return; @@ -4546,7 +4618,7 @@ class RealTimeMessagingManager extends ChangeNotifier { SCRoomTopLayerGuard().runWhenInactive(() { unawaited( _showRoomRocketRewardPopupDialog( - roomId, + normalizedRoomId, attempt: attempt, initialRoomRecords: initialRoomRecords, ), @@ -4554,16 +4626,21 @@ class RealTimeMessagingManager extends ChangeNotifier { }); return; } + if (!_roomRocketRewardPopupLoadingRoomIds.add(normalizedRoomId)) { + return; + } try { - final res = await SCChatRoomRepository().roomRocketRewardPopups(roomId); - if (!_isCurrentVisibleVoiceRoom(roomId)) { + final res = await SCChatRoomRepository().roomRocketRewardPopups( + normalizedRoomId, + ); + if (!_isCurrentVisibleVoiceRoom(normalizedRoomId)) { return; } if (res.records.isEmpty && initialRoomRecords.isEmpty && attempt < _roomRocketRewardPopupRetryDelays.length) { _scheduleRoomRocketRewardPopupRetry( - roomId, + normalizedRoomId, attempt, initialRoomRecords: initialRoomRecords, ); @@ -4581,44 +4658,103 @@ class RealTimeMessagingManager extends ChangeNotifier { _roomRocketRewardPopupRetryTimer?.cancel(); _roomRocketRewardPopupRetryTimer = null; SCRoomRocketRewardPopupAck.markShownAndAck(popupRecords); - final recordIds = SCRoomRocketRewardPopupAck.recordIds(popupRecords); final userProfileResolver = _roomRocketRewardUserProfileResolver(); - SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); - SmartDialog.show( - tag: _roomRocketRewardDialogTag, - alignment: Alignment.center, - debounce: true, - animationType: SmartAnimationType.fade, - maskColor: Colors.transparent, - clickMaskDismiss: false, - builder: - (_) => SCRoomTopLayerSuppressor( - reason: 'room_rocket_reward_dialog', - keepDialogTags: const {_roomRocketRewardDialogTag}, - child: RoomRocketRewardDialogLoader( - roomId: roomId, - initialPopupRecords: popupRecords, - initialRoomRecords: initialRoomRecords, - userProfileResolver: userProfileResolver, - onClose: () { - SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); - _scheduleRoomRocketPostLaunchStatusRefresh(roomId); - if (recordIds.isNotEmpty) { - SCRoomRocketRewardPopupAck.markShownAndAck(popupRecords); - } - }, - ), - ), + final popupGroups = RoomRocketApiMapper.rewardPopupRecordGroups( + popupRecords, + ); + final groupsToShow = + popupGroups.isNotEmpty + ? popupGroups + : const >[ + [], + ]; + _showRoomRocketRewardPopupGroups( + roomId: normalizedRoomId, + popupGroups: groupsToShow, + groupIndex: 0, + initialRoomRecords: initialRoomRecords, + userProfileResolver: userProfileResolver, ); } catch (_) { _scheduleRoomRocketRewardPopupRetry( - roomId, + normalizedRoomId, attempt, initialRoomRecords: initialRoomRecords, ); + } finally { + _roomRocketRewardPopupLoadingRoomIds.remove(normalizedRoomId); } } + void _showRoomRocketRewardPopupGroups({ + required String roomId, + required List> popupGroups, + required int groupIndex, + required List initialRoomRecords, + required RoomRocketRewardUserProfileResolver? userProfileResolver, + }) { + if (!_isCurrentVisibleVoiceRoom(roomId) || + groupIndex >= popupGroups.length) { + return; + } + if (SCRoomTopLayerGuard().isActive) { + SCRoomTopLayerGuard().runWhenInactive(() { + _showRoomRocketRewardPopupGroups( + roomId: roomId, + popupGroups: popupGroups, + groupIndex: groupIndex, + initialRoomRecords: initialRoomRecords, + userProfileResolver: userProfileResolver, + ); + }); + return; + } + final popupRecords = popupGroups[groupIndex]; + final roomRecords = RoomRocketApiMapper.rewardRoomRecordsForPopupGroup( + initialRoomRecords, + popupRecords, + ); + final nextGroupIndex = groupIndex + 1; + SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); + SmartDialog.show( + tag: _roomRocketRewardDialogTag, + alignment: Alignment.center, + debounce: true, + animationType: SmartAnimationType.fade, + maskColor: Colors.transparent, + clickMaskDismiss: false, + builder: + (_) => SCRoomTopLayerSuppressor( + reason: 'room_rocket_reward_dialog', + keepDialogTags: const {_roomRocketRewardDialogTag}, + child: RoomRocketRewardDialogLoader( + roomId: roomId, + initialPopupRecords: popupRecords, + initialRoomRecords: roomRecords, + userProfileResolver: userProfileResolver, + onClose: () { + SmartDialog.dismiss(tag: _roomRocketRewardDialogTag); + _scheduleRoomRocketPostLaunchStatusRefresh(roomId); + SCRoomRocketRewardPopupAck.markShownAndAck(popupRecords); + if (nextGroupIndex < popupGroups.length) { + unawaited( + Future.delayed(const Duration(milliseconds: 160), () { + _showRoomRocketRewardPopupGroups( + roomId: roomId, + popupGroups: popupGroups, + groupIndex: nextGroupIndex, + initialRoomRecords: initialRoomRecords, + userProfileResolver: userProfileResolver, + ); + }), + ); + } + }, + ), + ), + ); + } + void _scheduleRoomRocketRewardPopupRetry( String roomId, int attempt, { diff --git a/lib/services/audio/trtc_room_rtc_engine_adapter.dart b/lib/services/audio/trtc_room_rtc_engine_adapter.dart index e1dc05c..e0cca47 100644 --- a/lib/services/audio/trtc_room_rtc_engine_adapter.dart +++ b/lib/services/audio/trtc_room_rtc_engine_adapter.dart @@ -34,6 +34,7 @@ abstract class RoomTrtcAudioEffectManager { void resumePlayMusic(int id); void setMusicPublishVolume(int id, int volume); void setMusicPlayoutVolume(int id, int volume); + void setVoiceCaptureVolume(int volume); int getMusicCurrentPosInMS(int id); int getMusicDurationInMS(String path); void seekMusicToPosInTime(int id, int pts); @@ -156,6 +157,11 @@ class TencentRoomTrtcAudioEffectManager implements RoomTrtcAudioEffectManager { _manager.setMusicPlayoutVolume(id, volume); } + @override + void setVoiceCaptureVolume(int volume) { + _manager.setVoiceCaptureVolume(volume); + } + @override int getMusicCurrentPosInMS(int id) { return _manager.getMusicCurrentPosInMS(id); @@ -364,7 +370,8 @@ class TrtcRoomRtcEngineAdapter implements RoomRtcEngineAdapter { try { cloud.switchRole(TRTCRoleType.anchor); cloud.startLocalAudio(TRTCAudioQuality.defaultMode); - cloud.muteLocalAudio(config.muted); + _musicManager?.setVoiceCaptureVolume(config.muted ? 0 : 100); + cloud.muteLocalAudio(config.publishMuted); _isLocalAudioStarted = true; return true; } catch (_) { diff --git a/lib/services/auth/user_profile_manager.dart b/lib/services/auth/user_profile_manager.dart index cacbefa..a42682a 100644 --- a/lib/services/auth/user_profile_manager.dart +++ b/lib/services/auth/user_profile_manager.dart @@ -180,6 +180,17 @@ class SocialChatUserProfileManager extends ChangeNotifier { } final cpPairs = []; final closeFriendPairs = []; + _addCpRelationPairs( + cpPairs: cpPairs, + closeFriendPairs: closeFriendPairs, + relations: profile.cpList, + fallbackRelationType: 'CP', + ); + _addCpRelationPairs( + cpPairs: cpPairs, + closeFriendPairs: closeFriendPairs, + relations: profile.closeFriendList, + ); await Future.wait( _cpProfileRelationTypes.map((relationType) async { final cabin = await _safeLoadCpCabin(userId, relationType); @@ -206,11 +217,12 @@ class SocialChatUserProfileManager extends ChangeNotifier { levelText: rightsLevelText ?? cabinLevelText, dismissCost: cpPair.dismissCost ?? _cpDismissCostFromCabin(cabin), ); - if (normalizedType == 'CP') { - cpPairs.add(enrichedPair); - } else { - closeFriendPairs.add(enrichedPair); - } + _addCpRelationPair( + cpPairs: cpPairs, + closeFriendPairs: closeFriendPairs, + relation: enrichedPair, + fallbackRelationType: normalizedType, + ); }), ); if (kDebugMode) { @@ -226,6 +238,81 @@ class SocialChatUserProfileManager extends ChangeNotifier { ); } + void _addCpRelationPairs({ + required List cpPairs, + required List closeFriendPairs, + required List? relations, + String? fallbackRelationType, + }) { + for (final relation in relations ?? const []) { + _addCpRelationPair( + cpPairs: cpPairs, + closeFriendPairs: closeFriendPairs, + relation: relation, + fallbackRelationType: fallbackRelationType, + ); + } + } + + void _addCpRelationPair({ + required List cpPairs, + required List closeFriendPairs, + required CPRes relation, + String? fallbackRelationType, + }) { + if (!_hasCpPair(relation)) { + return; + } + final relationTypeSource = + (relation.relationType?.trim().isNotEmpty ?? false) + ? relation.relationType + : fallbackRelationType; + if ((relationTypeSource ?? "").trim().isEmpty) { + return; + } + final normalizedType = _normalizeCpRelationType(relationTypeSource); + final normalizedRelation = relation.copyWith(relationType: normalizedType); + final target = normalizedType == 'CP' ? cpPairs : closeFriendPairs; + final relationKey = _cpRelationPairKey(normalizedRelation); + final existingIndex = target.indexWhere( + (item) => _cpRelationPairKey(item) == relationKey, + ); + if (existingIndex >= 0) { + target[existingIndex] = normalizedRelation; + } else { + target.add(normalizedRelation); + } + } + + String _cpRelationPairKey(CPRes relation) { + final type = _normalizeCpRelationType(relation.relationType); + final userIds = [ + relation.meUserId?.trim() ?? "", + relation.cpUserId?.trim() ?? "", + ]..removeWhere((value) => value.isEmpty); + if (userIds.length >= 2) { + userIds.sort(); + return '$type|id|${userIds.join("|")}'; + } + final accounts = [ + relation.meAccount?.trim() ?? "", + relation.cpAccount?.trim() ?? "", + ]..removeWhere((value) => value.isEmpty); + if (accounts.length >= 2) { + accounts.sort(); + return '$type|account|${accounts.join("|")}'; + } + return [ + type, + relation.meUserId?.trim() ?? "", + relation.meAccount?.trim() ?? "", + relation.cpUserId?.trim() ?? "", + relation.cpAccount?.trim() ?? "", + relation.cpUserNickname?.trim() ?? "", + relation.meUserNickname?.trim() ?? "", + ].join('|'); + } + Future _safeLoadCpCabin( String userId, String relationType, diff --git a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart index dc625f8..1d1d705 100644 --- a/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart +++ b/lib/shared/business_logic/models/res/sc_room_rocket_api_res.dart @@ -355,12 +355,145 @@ class SCRoomRocketRewardRes { factory SCRoomRocketRewardRes.fromJson(dynamic json) { final data = _asMap(json); + final reward = _asMap( + _firstPayload([ + data['reward'], + data['rewardInfo'], + data['reward_info'], + data['rewardConfig'], + data['reward_config'], + data['propsResources'], + data['props_resources'], + data['resource'], + ]), + ); + final resource = _asMap( + _firstPayload([ + data['propsResources'], + data['props_resources'], + data['resource'], + reward['propsResources'], + reward['props_resources'], + reward['resource'], + ]), + ); + final rewardType = _stringValue( + data['rewardType'] ?? + data['reward_type'] ?? + data['detailType'] ?? + data['detail_type'] ?? + data['type'] ?? + reward['rewardType'] ?? + reward['reward_type'] ?? + reward['detailType'] ?? + reward['detail_type'] ?? + reward['type'] ?? + resource['type'], + ); + final rewardAmountSource = + _isGoldRewardType(rewardType) + ? _firstPayload([ + data['rewardAmount'], + data['reward_amount'], + data['amount'], + data['content'], + data['rewardQuantity'], + data['reward_quantity'], + data['quantity'], + data['count'], + data['num'], + reward['rewardAmount'], + reward['reward_amount'], + reward['amount'], + reward['content'], + reward['rewardQuantity'], + reward['reward_quantity'], + reward['quantity'], + reward['count'], + reward['num'], + ]) + : _firstPayload([ + data['rewardAmount'], + data['reward_amount'], + data['rewardQuantity'], + data['reward_quantity'], + data['quantity'], + data['count'], + data['num'], + data['amount'], + data['content'], + reward['rewardAmount'], + reward['reward_amount'], + reward['rewardQuantity'], + reward['reward_quantity'], + reward['quantity'], + reward['count'], + reward['num'], + reward['amount'], + reward['content'], + ]); return SCRoomRocketRewardRes( - rewardScene: _stringValue(data['rewardScene']), - rewardType: _stringValue(data['rewardType']), - rewardItemId: _stringValue(data['rewardItemId']), - rewardName: _stringValue(data['rewardName']), - rewardCover: _stringValue(data['rewardCover']), + rewardScene: _stringValue( + data['rewardScene'] ?? + data['reward_scene'] ?? + reward['rewardScene'] ?? + reward['reward_scene'], + ), + rewardType: rewardType, + rewardItemId: _stringValue( + data['rewardItemId'] ?? + data['reward_item_id'] ?? + data['itemId'] ?? + data['item_id'] ?? + data['resourceId'] ?? + data['resource_id'] ?? + reward['rewardItemId'] ?? + reward['reward_item_id'] ?? + reward['itemId'] ?? + reward['item_id'] ?? + reward['resourceId'] ?? + reward['resource_id'] ?? + reward['id'] ?? + reward['amount'] ?? + reward['content'] ?? + resource['id'], + ), + rewardName: _stringValue( + data['rewardName'] ?? + data['reward_name'] ?? + data['name'] ?? + data['badgeName'] ?? + data['badge_name'] ?? + reward['rewardName'] ?? + reward['reward_name'] ?? + reward['name'] ?? + reward['badgeName'] ?? + reward['badge_name'] ?? + resource['name'] ?? + resource['badgeName'] ?? + resource['badge_name'], + ), + rewardCover: _firstAssetString([ + data['rewardCover'], + data['reward_cover'], + data['cover'], + data['sourceUrl'], + data['source_url'], + data['iconUrl'], + data['icon_url'], + reward['rewardCover'], + reward['reward_cover'], + reward['cover'], + reward['sourceUrl'], + reward['source_url'], + reward['iconUrl'], + reward['icon_url'], + resource['cover'], + resource['sourceUrl'], + resource['source_url'], + resource['iconUrl'], + resource['icon_url'], + ]), rocketIconUrl: _firstAssetString([ data['rocket_icon_url'], data['rocketIconUrl'], @@ -376,8 +509,28 @@ class SCRoomRocketRewardRes { _asMap(data['config'])['rocketIconUrl'], _asMap(data['config'])['rocketIcon'], ]), - rewardAmount: _rewardAmountValue(data['rewardAmount']) ?? 0, - expireDays: _dayValue(data['expireDays']), + rewardAmount: _rewardAmountValue(rewardAmountSource) ?? 0, + expireDays: _dayValue( + _firstPayload([ + data['expireDays'], + data['expire_days'], + data['durationDays'], + data['duration_days'], + data['validDays'], + data['valid_days'], + data['validityDays'], + data['validity_days'], + reward['expireDays'], + reward['expire_days'], + reward['durationDays'], + reward['duration_days'], + reward['validDays'], + reward['valid_days'], + reward['validityDays'], + reward['validity_days'], + reward['quantity'], + ]), + ), ); } @@ -578,11 +731,20 @@ class SCRoomRocketRewardPreviewRes { static bool _looksLikeReward(Map data) { return data.containsKey('rewardScene') || + data.containsKey('reward_scene') || data.containsKey('rewardType') || + data.containsKey('reward_type') || data.containsKey('rewardItemId') || + data.containsKey('reward_item_id') || data.containsKey('rewardName') || + data.containsKey('reward_name') || data.containsKey('rewardCover') || - data.containsKey('rewardAmount'); + data.containsKey('reward_cover') || + data.containsKey('rewardAmount') || + data.containsKey('reward_amount') || + data.containsKey('reward') || + data.containsKey('rewardInfo') || + data.containsKey('reward_info'); } static String _normalizedScene(String value) { @@ -925,6 +1087,102 @@ class SCRoomRocketRewardRecordRes { winner, ]), ); + final reward = _asMap( + _firstPayload([ + data['reward'], + data['rewardInfo'], + data['reward_info'], + data['rewardConfig'], + data['reward_config'], + data['propsResources'], + data['props_resources'], + data['resource'], + ]), + ); + final resource = _asMap( + _firstPayload([ + data['propsResources'], + data['props_resources'], + data['resource'], + reward['propsResources'], + reward['props_resources'], + reward['resource'], + ]), + ); + final rewardType = _stringValue( + data['rewardType'] ?? + data['reward_type'] ?? + data['detailType'] ?? + data['detail_type'] ?? + data['type'] ?? + reward['rewardType'] ?? + reward['reward_type'] ?? + reward['detailType'] ?? + reward['detail_type'] ?? + reward['type'] ?? + resource['type'], + ); + final rewardAmountSource = + _isGoldRewardType(rewardType) + ? _firstPayload([ + data['rewardAmount'], + data['reward_amount'], + data['amount'], + data['content'], + data['rewardQuantity'], + data['reward_quantity'], + data['quantity'], + data['count'], + data['num'], + reward['rewardAmount'], + reward['reward_amount'], + reward['amount'], + reward['content'], + reward['rewardQuantity'], + reward['reward_quantity'], + reward['quantity'], + reward['count'], + reward['num'], + ]) + : _firstPayload([ + data['rewardAmount'], + data['reward_amount'], + data['rewardQuantity'], + data['reward_quantity'], + data['quantity'], + data['count'], + data['num'], + data['amount'], + data['content'], + reward['rewardAmount'], + reward['reward_amount'], + reward['rewardQuantity'], + reward['reward_quantity'], + reward['quantity'], + reward['count'], + reward['num'], + reward['amount'], + reward['content'], + ]); + final expireDaysSource = _firstPayload([ + data['expireDays'], + data['expire_days'], + data['durationDays'], + data['duration_days'], + data['validDays'], + data['valid_days'], + data['validityDays'], + data['validity_days'], + reward['expireDays'], + reward['expire_days'], + reward['durationDays'], + reward['duration_days'], + reward['validDays'], + reward['valid_days'], + reward['validityDays'], + reward['validity_days'], + reward['quantity'], + ]); return SCRoomRocketRewardRecordRes( id: _stringValue(data['id']), launchNo: _stringValue(data['launchNo'] ?? data['launch_no']), @@ -1085,16 +1343,68 @@ class SCRoomRocketRewardRecordRes { profile['actual_account'], ]), ), - rewardScene: _stringValue(data['rewardScene'] ?? data['reward_scene']), - rewardType: _stringValue(data['rewardType'] ?? data['reward_type']), - rewardItemId: _stringValue( - data['rewardItemId'] ?? data['reward_item_id'], + rewardScene: _stringValue( + data['rewardScene'] ?? + data['reward_scene'] ?? + reward['rewardScene'] ?? + reward['reward_scene'], ), - rewardName: _stringValue(data['rewardName'] ?? data['reward_name']), - rewardCover: _stringValue(data['rewardCover'] ?? data['reward_cover']), - rewardAmount: - _rewardAmountValue(data['rewardAmount'] ?? data['reward_amount']) ?? - 0, + rewardType: rewardType, + rewardItemId: _stringValue( + data['rewardItemId'] ?? + data['reward_item_id'] ?? + data['itemId'] ?? + data['item_id'] ?? + data['resourceId'] ?? + data['resource_id'] ?? + reward['rewardItemId'] ?? + reward['reward_item_id'] ?? + reward['itemId'] ?? + reward['item_id'] ?? + reward['resourceId'] ?? + reward['resource_id'] ?? + reward['id'] ?? + reward['amount'] ?? + reward['content'] ?? + resource['id'], + ), + rewardName: _stringValue( + data['rewardName'] ?? + data['reward_name'] ?? + data['name'] ?? + data['badgeName'] ?? + data['badge_name'] ?? + reward['rewardName'] ?? + reward['reward_name'] ?? + reward['name'] ?? + reward['badgeName'] ?? + reward['badge_name'] ?? + resource['name'] ?? + resource['badgeName'] ?? + resource['badge_name'], + ), + rewardCover: _firstAssetString([ + data['rewardCover'], + data['reward_cover'], + data['cover'], + data['sourceUrl'], + data['source_url'], + data['iconUrl'], + data['icon_url'], + reward['rewardCover'], + reward['reward_cover'], + reward['cover'], + reward['sourceUrl'], + reward['source_url'], + reward['iconUrl'], + reward['icon_url'], + resource['cover'], + resource['sourceUrl'], + resource['source_url'], + resource['iconUrl'], + resource['icon_url'], + ]), + rewardAmount: _rewardAmountValue(rewardAmountSource) ?? 0, rocketIconUrl: _firstAssetString([ data['rocket_icon_url'], data['rocketIconUrl'], @@ -1110,7 +1420,7 @@ class SCRoomRocketRewardRecordRes { _asMap(data['config'])['rocketIconUrl'], _asMap(data['config'])['rocketIcon'], ]), - expireDays: _dayValue(data['expireDays'] ?? data['expire_days']), + expireDays: _dayValue(expireDaysSource), grantStatus: _stringValue(data['grantStatus'] ?? data['grant_status']), popupStatus: _stringValue(data['popupStatus'] ?? data['popup_status']), grantTime: _stringValue(data['grantTime'] ?? data['grant_time']), @@ -1253,6 +1563,9 @@ bool _looksLikeRewardRecord(Map data) { 'reward_cover', 'rewardAmount', 'reward_amount', + 'reward', + 'rewardInfo', + 'reward_info', 'grantStatus', 'grant_status', 'popupStatus', @@ -1420,6 +1733,15 @@ num? _rewardAmountValue(dynamic value) { return _numValue(value) ?? _dayNumberValue(value); } +bool _isGoldRewardType(String value) { + final normalized = value.trim().toUpperCase(); + return normalized == 'GOLD' || + normalized == 'COIN' || + normalized == 'COINS' || + normalized == 'GOLD_COIN' || + normalized == 'GOLD_COINS'; +} + int _dayValue(dynamic value, {int fallback = 0}) { final numeric = _numValue(value) ?? _dayNumberValue(value); return numeric?.toInt() ?? fallback; diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 64d8470..b45c4f7 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -937,10 +937,21 @@ class SCChatRoomRepository implements SocialChatRoomRepository { ///room/setting/kick-off-microphone @override - Future kickOffMicrophone(String roomId, String kickUserId) async { + Future kickOffMicrophone( + String roomId, + String kickUserId, { + num? mickIndex, + }) async { + final data = { + "roomId": roomId, + "kickUserId": kickUserId, + "userId": kickUserId, + if (mickIndex != null) "mickIndex": mickIndex, + if (mickIndex != null) "micIndex": mickIndex, + }; final result = await http.post( "a4465894e21be08d833ef701566ffe1b6a835338046b35fb94271445c996f6e3574aff257ce7e668d08f4caccd1c6232", - data: {"roomId": roomId, "kickUserId": kickUserId}, + data: data, fromJson: (json) => json as bool, ); return result; diff --git a/lib/shared/tools/sc_cp_relation_pair_utils.dart b/lib/shared/tools/sc_cp_relation_pair_utils.dart index 7d095a8..85f9677 100644 --- a/lib/shared/tools/sc_cp_relation_pair_utils.dart +++ b/lib/shared/tools/sc_cp_relation_pair_utils.dart @@ -75,6 +75,9 @@ SCCpRelationPairInfo? _relationInfoIfContainsBoth( SocialChatUserProfile left, SocialChatUserProfile right, ) { + if (_isInactiveRelationStatus(relation.status)) { + return null; + } final leftKeys = _profileKeys(left); final rightKeys = _profileKeys(right); if (leftKeys.isEmpty || rightKeys.isEmpty) { @@ -92,6 +95,21 @@ SCCpRelationPairInfo? _relationInfoIfContainsBoth( ); } +bool _isInactiveRelationStatus(String? status) { + final normalizedStatus = status?.trim().toUpperCase() ?? ""; + return const { + "NONE", + "INACTIVE", + "DISMISSED", + "DISSOLVED", + "CANCELLED", + "CANCELED", + "EXPIRED", + "ENDED", + "REJECTED", + }.contains(normalizedStatus); +} + bool _hasRelationIdentity(SocialChatUserProfile? profile) => _profileKeys(profile).isNotEmpty; 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 f6e39c9..1d5bfec 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 @@ -29,6 +29,58 @@ class RoomRocketRewardUserProfile { class RoomRocketApiMapper { const RoomRocketApiMapper._(); + static List> rewardPopupRecordGroups( + List records, + ) { + if (records.isEmpty) { + return const >[]; + } + final order = []; + final groups = >{}; + for (var index = 0; index < records.length; index += 1) { + final record = records[index]; + final launchNo = record.launchNo.trim(); + final id = record.id.trim(); + final key = + launchNo.isNotEmpty + ? 'launch:$launchNo' + : id.isNotEmpty + ? 'record:$id' + : 'index:$index'; + if (!groups.containsKey(key)) { + order.add(key); + groups[key] = []; + } + groups[key]!.add(record); + } + return order + .map( + (key) => List.unmodifiable(groups[key]!), + ) + .toList(growable: false); + } + + static List rewardRoomRecordsForPopupGroup( + List roomRecords, + List popupRecords, + ) { + if (roomRecords.isEmpty || popupRecords.isEmpty) { + return roomRecords; + } + final launchNos = + popupRecords + .map((record) => record.launchNo.trim()) + .where((item) => item.isNotEmpty) + .toSet(); + if (launchNos.isEmpty) { + return roomRecords; + } + final matched = roomRecords + .where((record) => launchNos.contains(record.launchNo.trim())) + .toList(growable: false); + return matched.isEmpty ? roomRecords : matched; + } + static List? levelsFromStatus( SCRoomRocketStatusRes? status, ) { diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart index 6d2aefc..b9332df 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_reward_dialog_loader.dart @@ -88,12 +88,12 @@ class _RoomRocketRewardDialogLoaderState if (useInitialPopupRecords) { _usedInitialPopupRecords = true; } - final launchNo = _launchNoFromRecords( + final launchNos = _launchNosFromRecords( nextPopupRecords.isNotEmpty ? nextPopupRecords : _popupRecords, ); - final nextRoomRecords = await _loadRoomRewardRecords( + final nextRoomRecordPage = await _loadRoomRewardRecordPage( requestCursor, - launchNo: launchNo, + launchNos: launchNos, ); if (!mounted) { return; @@ -103,17 +103,17 @@ class _RoomRocketRewardDialogLoaderState _replaceUniqueRewardRecords(_popupRecords, nextPopupRecords); _replaceUniqueRewardRecords(_roomRecords, [ ...widget.initialRoomRecords, - ...nextRoomRecords, + ...nextRoomRecordPage.records, ]); } else { - _appendUniqueRewardRecords(_roomRecords, nextRoomRecords); + _appendUniqueRewardRecords(_roomRecords, nextRoomRecordPage.records); } _rewards = RoomRocketApiMapper.rewardItemsFromRecords(_popupRecords); _winners = RoomRocketApiMapper.winnersFromRecords( _visibleRoomRewardRecords(), userProfileResolver: widget.userProfileResolver, ); - _hasMore = nextRoomRecords.length >= _rewardDialogPageSize; + _hasMore = nextRoomRecordPage.hasMore; if (_hasMore) { _cursor = requestCursor + 1; } @@ -125,7 +125,8 @@ class _RoomRocketRewardDialogLoaderState 'popupCount=${nextPopupRecords.length} ' 'popupTotal=${_popupRecords.length} ' 'initialRoomCount=${widget.initialRoomRecords.length} ' - 'roomCount=${nextRoomRecords.length} ' + 'launchNos=${launchNos.join(",")} ' + 'roomCount=${nextRoomRecordPage.records.length} ' 'roomTotal=${_roomRecords.length} ' 'visibleRoomTotal=${_visibleRoomRewardRecords().length} ' 'hasMore=$_hasMore nextCursor=$_cursor', @@ -167,7 +168,7 @@ class _RoomRocketRewardDialogLoaderState try { _rewardDialogDebug( 'request reward-room-records roomId=${widget.roomId} ' - 'cursor=$cursor limit=$_rewardDialogPageSize', + 'cursor=$cursor limit=$_rewardDialogPageSize launchNo=$launchNo', ); final res = await SCChatRoomRepository().roomRocketRewardRecords( widget.roomId, @@ -183,6 +184,39 @@ class _RoomRocketRewardDialogLoaderState } } + Future<_RoomRocketRewardRecordPage> _loadRoomRewardRecordPage( + int cursor, { + required Set launchNos, + }) async { + final normalizedLaunchNos = + launchNos.where((item) => item.trim().isNotEmpty).toSet(); + if (normalizedLaunchNos.isEmpty) { + final records = await _loadRoomRewardRecords(cursor); + return _RoomRocketRewardRecordPage( + records: records, + hasMore: records.length >= _rewardDialogPageSize, + ); + } + final pages = await Future.wait( + normalizedLaunchNos.map((launchNo) async { + final records = await _loadRoomRewardRecords( + cursor, + launchNo: launchNo, + ); + return _RoomRocketRewardRecordPage( + records: records, + hasMore: records.length >= _rewardDialogPageSize, + ); + }), + ); + return _RoomRocketRewardRecordPage( + records: debugUniqueRoomRocketRewardRecords( + pages.expand((page) => page.records).toList(growable: false), + ), + hasMore: pages.any((page) => page.hasMore), + ); + } + List _visibleRoomRewardRecords() { if (_roomRecords.isEmpty) { return _popupRecords; @@ -217,14 +251,21 @@ class _RoomRocketRewardDialogLoaderState } } -String _launchNoFromRecords(List records) { - for (final record in records) { - final launchNo = record.launchNo.trim(); - if (launchNo.isNotEmpty) { - return launchNo; - } - } - return ''; +class _RoomRocketRewardRecordPage { + const _RoomRocketRewardRecordPage({ + required this.records, + required this.hasMore, + }); + + final List records; + final bool hasMore; +} + +Set _launchNosFromRecords(List records) { + return records + .map((record) => record.launchNo.trim()) + .where((launchNo) => launchNo.isNotEmpty) + .toSet(); } void _replaceUniqueRewardRecords( diff --git a/lib/ui_kit/widgets/room/room_user_info_card.dart b/lib/ui_kit/widgets/room/room_user_info_card.dart index f86ba07..e362daa 100644 --- a/lib/ui_kit/widgets/room/room_user_info_card.dart +++ b/lib/ui_kit/widgets/room/room_user_info_card.dart @@ -290,7 +290,7 @@ class _RoomUserInfoCardState extends State { required bool canShowSetting, }) { final hasCpRelation = _hasCpRelation(profile); - final closeFriends = _closeFriendProfileData(profile).take(5).toList(); + final closeFriends = _closeFriendProfileData(profile); return Stack( children: [ @@ -790,7 +790,8 @@ class _RoomUserInfoCardState extends State { } List _closeFriendProfileData(SocialChatUserProfile? profile) { - final avatars = []; + final brotherAvatars = []; + final sisterAvatars = []; for (final relation in profile?.closeFriendList ?? const []) { final type = _normalizeCpRelationType(relation.relationType); if (type != "BROTHER" && type != "SISTERS") { @@ -802,10 +803,14 @@ class _RoomUserInfoCardState extends State { final friend = _relationPartner(profile, relation); final avatar = friend.avatarUrl?.trim() ?? ""; if (avatar.isNotEmpty) { - avatars.add(avatar); + if (type == "BROTHER" && brotherAvatars.length < 3) { + brotherAvatars.add(avatar); + } else if (type == "SISTERS" && sisterAvatars.length < 3) { + sisterAvatars.add(avatar); + } } } - return avatars; + return [...brotherAvatars, ...sisterAvatars]; } Widget _buildCloseFriendAvatar(String avatarUrl) { @@ -1525,6 +1530,16 @@ class _RoomUserInfoCardState extends State { partnerUserId, relationType: cp.relationType, ); + if (context.mounted) { + Provider.of( + context, + listen: false, + )?.invalidateRoomMicRelationBetween( + userId: cp.leftUserId, + peerUserId: partnerUserId, + relationType: cp.relationType, + ); + } await userProvider?.refreshLoadedCpProfiles(); await SmartDialog.dismiss(tag: RoomCpProgressDialog.dialogTag); SCTts.show(l10n.operationSuccessful); diff --git a/test/room_rocket_status_test.dart b/test/room_rocket_status_test.dart index 2707acd..358ce17 100644 --- a/test/room_rocket_status_test.dart +++ b/test/room_rocket_status_test.dart @@ -78,6 +78,34 @@ void main() { expect(text, isNot(contains('4. Leaderboard'))); }); + test('groups unread reward popups by launch number', () { + final groups = RoomRocketApiMapper.rewardPopupRecordGroups([ + _rewardRecord(id: 'r1', launchNo: 'launch-a'), + _rewardRecord(id: 'r2', launchNo: 'launch-a'), + _rewardRecord(id: 'r3', launchNo: 'launch-b'), + _rewardRecord(id: 'r4', launchNo: ''), + _rewardRecord(id: '', launchNo: ''), + ]); + + expect(groups.map((group) => group.length), [2, 1, 1, 1]); + expect(groups.first.map((record) => record.id), ['r1', 'r2']); + expect(groups[1].single.launchNo, 'launch-b'); + }); + + test('matches room winner records to the current popup batch', () { + final roomRecords = [ + _rewardRecord(id: 'room-a', launchNo: 'launch-a'), + _rewardRecord(id: 'room-b', launchNo: 'launch-b'), + ]; + final matched = RoomRocketApiMapper.rewardRoomRecordsForPopupGroup( + roomRecords, + [_rewardRecord(id: 'popup-b', launchNo: 'launch-b')], + ); + + expect(matched, hasLength(1)); + expect(matched.single.id, 'room-b'); + }); + test( 'parses level-specific reward previews and rocket kings from levels', () { @@ -494,6 +522,30 @@ void main() { expect(winners.last.rewards.single.amount, '200'); }); + test('parses nested rocket reward record resource payloads', () { + final record = SCRoomRocketRewardRecordRes.fromJson({ + 'id': 'r1', + 'launchNo': 'launch-1', + 'roomId': 'room-1', + 'userInfo': {'id': 'u1', 'nickname': 'winner'}, + 'reward': { + 'detailType': 'AVATAR_FRAME', + 'amount': '1969014438069481474', + 'quantity': 7, + 'name': 'yumi-KingTOP1 frame', + 'cover': {'sourceUrl': 'https://example.com/frame.png'}, + }, + }); + final reward = RoomRocketApiMapper.rewardItemsFromRecords([record]).single; + + expect(record.rewardType, 'AVATAR_FRAME'); + expect(record.rewardItemId, isNotEmpty); + expect(record.rewardName, 'yumi-KingTOP1 frame'); + expect(record.rewardCover, 'https://example.com/frame.png'); + expect(record.expireDays, 7); + expect(reward.displayText, '7d'); + }); + test('parses rocket level contents response from go api', () { final contents = SCRoomRocketLevelContentsRes.fromJson({ 'configured': true, @@ -565,3 +617,26 @@ void main() { expect(groups?.setOff.single.amount, '200'); }); } + +SCRoomRocketRewardRecordRes _rewardRecord({ + required String id, + required String launchNo, +}) { + return SCRoomRocketRewardRecordRes.fromJson({ + 'id': id, + 'launchNo': launchNo, + 'roomId': 'room-1', + 'dayKey': '2026-05-15', + 'roundNo': 1, + 'level': 3, + 'userId': 'user-1', + 'userNickname': 'User 1', + 'rewardScene': 'IN_ROOM', + 'rewardType': 'GOLD', + 'rewardItemId': 'gold', + 'rewardName': 'Yumi', + 'rewardAmount': 100, + 'grantStatus': 'SUCCESS', + 'popupStatus': 'UNREAD', + }); +} diff --git a/test/room_seat_widget_test.dart b/test/room_seat_widget_test.dart index dbb7bcc..3c09583 100644 --- a/test/room_seat_widget_test.dart +++ b/test/room_seat_widget_test.dart @@ -134,6 +134,49 @@ void main() { }, ); + test('micChange keeps a recent up-mic snapshot over stale mic list data', () { + final rtcProvider = RealTimeCommunicationManager(); + final user = SocialChatUserProfile(id: 'user-1', userNickname: 'Alice'); + + rtcProvider.micChange([MicRes(micIndex: 0, user: user)]); + expect(rtcProvider.roomWheatMap[0]?.user?.id, 'user-1'); + + rtcProvider.debugApplyMicListSnapshot([ + MicRes(micIndex: 0), + ], notifyIfUnchanged: false); + + expect(rtcProvider.roomWheatMap[0]?.user?.id, 'user-1'); + }); + + test( + 'micChange keeps a recent down-mic snapshot over stale mic list data', + () { + final rtcProvider = + RealTimeCommunicationManager() + ..roomWheatMap = { + 0: MicRes( + micIndex: 0, + user: SocialChatUserProfile( + id: 'user-1', + userNickname: 'Alice', + ), + ), + }; + + rtcProvider.micChange([MicRes(micIndex: 0)]); + expect(rtcProvider.roomWheatMap[0]?.user, isNull); + + rtcProvider.debugApplyMicListSnapshot([ + MicRes( + micIndex: 0, + user: SocialChatUserProfile(id: 'user-1', userNickname: 'Alice'), + ), + ], notifyIfUnchanged: false); + + expect(rtcProvider.roomWheatMap[0]?.user, isNull); + }, + ); + testWidgets('RoomSeatWidget derives seat count from highest mic index', ( tester, ) async { diff --git a/test/sc_cp_relation_pair_utils_test.dart b/test/sc_cp_relation_pair_utils_test.dart index 15ebc69..af74dd1 100644 --- a/test/sc_cp_relation_pair_utils_test.dart +++ b/test/sc_cp_relation_pair_utils_test.dart @@ -78,6 +78,23 @@ void main() { expect(scCpRelationTypeBetweenProfiles(left, right), isNull); }); + + test('ignores inactive relation payloads', () { + final left = SocialChatUserProfile( + id: '100', + cpList: [ + CPRes( + meUserId: '100', + cpUserId: '200', + relationType: 'CP', + status: 'DISMISSED', + ), + ], + ); + final right = SocialChatUserProfile(id: '200'); + + expect(scCpRelationTypeBetweenProfiles(left, right), isNull); + }); }); group('scCpRelationLevelTextFromCabin', () { diff --git a/test/trtc_room_rtc_engine_adapter_test.dart b/test/trtc_room_rtc_engine_adapter_test.dart index 480a233..cedf733 100644 --- a/test/trtc_room_rtc_engine_adapter_test.dart +++ b/test/trtc_room_rtc_engine_adapter_test.dart @@ -55,6 +55,24 @@ void main() { expect(cloud.localAudioMuted, isTrue); }); + test('keeps music publishing while microphone voice is muted', () async { + final cloud = _FakeTrtcCloud(); + final adapter = TrtcRoomRtcEngineAdapter(cloudFactory: () async => cloud); + + await _enter(adapter); + expect( + await adapter.switchToAnchor( + const RoomRtcAnchorConfig(muted: true, publishMuted: false), + ), + isTrue, + ); + + expect(cloud.lastRole, TRTCRoleType.anchor); + expect(cloud.localAudioStarted, isTrue); + expect(cloud.localAudioMuted, isFalse); + expect(cloud.audioEffectManager.voiceCaptureVolume, 0); + }); + test('maps TRTC music calls to AudioEffectManager', () async { final cloud = _FakeTrtcCloud(); final adapter = TrtcRoomRtcEngineAdapter(cloudFactory: () async => cloud); @@ -197,6 +215,7 @@ class _FakeAudioEffectManager implements RoomTrtcAudioEffectManager { final resumedIds = []; int? playoutVolume; int? publishVolume; + int? voiceCaptureVolume; int position = 0; @override @@ -233,6 +252,11 @@ class _FakeAudioEffectManager implements RoomTrtcAudioEffectManager { playoutVolume = volume; } + @override + void setVoiceCaptureVolume(int volume) { + voiceCaptureVolume = volume; + } + @override int getMusicCurrentPosInMS(int id) { return position;