This commit is contained in:
roxy 2026-05-23 15:33:14 +08:00
parent 27f4e75cb8
commit 44e10028b9
17 changed files with 1453 additions and 276 deletions

View File

@ -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<GiftPage> with TickerProviderStateMixin {
}
await _refreshGiftBackpack();
_refreshCpStateAfterGift(request, profileManager);
_showCpGiftWaitingDialog(request);
return;
}
@ -1629,7 +1626,6 @@ class _GiftPageState extends State<GiftPage> 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<GiftPage> 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<List<MicRes>> _giveBackpackGiftToRecipients(
SCChatRoomRepository repository,
_GiftSendRequest request,

View File

@ -1383,14 +1383,20 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
}
List<CPRes> _profileCloseFriendRelations(SocialChatUserProfile? profile) {
return (profile?.closeFriendList ?? const <CPRes>[])
.where((relation) {
final type = _normalizeProfileRelationType(relation.relationType);
return (type == "BROTHER" || type == "SISTERS") &&
_profileRelationHasPartner(profile, relation);
})
.take(6)
.toList();
final brothers = <CPRes>[];
final sisters = <CPRes>[];
for (final relation in profile?.closeFriendList ?? const <CPRes>[]) {
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 <CPRes>[...brothers, ...sisters];
}
bool _profileRelationHasPartner(
@ -1611,6 +1617,14 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
if (!mounted) {
return;
}
Provider.of<RtcProvider?>(
context,
listen: false,
)?.invalidateRoomMicRelationBetween(
userId: payload.leftUserId,
peerUserId: partnerUserId,
relationType: payload.relationType,
);
await Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
@ -2589,6 +2603,20 @@ class _PersonDetailPageState extends State<PersonDetailPage> {
relationType: cpRelation?.relationType ?? "CP",
)
.then((result) {
if (mounted) {
Provider.of<RtcProvider?>(
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(

View File

@ -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 {

View File

@ -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<Duration> _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<num, _RecentMicChangeSeatSnapshot> _recentMicChangeSeatSnapshots =
<num, _RecentMicChangeSeatSnapshot>{};
num? _preferredSelfMicIndex;
num? _pendingSelfMicSourceIndex;
int? _pendingSelfMicSwitchGuardUntilMs;
@ -439,6 +454,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
final Map<String, _RoomMicRelationProfileCache> _roomMicRelationCache =
<String, _RoomMicRelationProfileCache>{};
final Set<String> _roomMicRelationLoadingUserIds = <String>{};
final Map<String, DateTime> _roomMicRelationDismissedPairUntil =
<String, DateTime>{};
final Set<String> _roomMusicPublishingUserIds = <String>{};
final Map<String, Timer> _roomMusicPublishingClearTimers = <String, Timer>{};
@ -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<SCRoomRedPacketListRes> 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<MicRes> 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<num, MicRes> _stabilizeMicListSnapshotWithRecentMicChange(
Map<num, MicRes> nextMap,
) {
final nowMs = DateTime.now().millisecondsSinceEpoch;
_pruneRecentMicChangeSeatSnapshots(nowMs);
if (_recentMicChangeSeatSnapshots.isEmpty) {
return nextMap;
}
Map<num, MicRes>? stabilizedMap;
final confirmedSeatIndexes = <num>[];
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<num, MicRes>.from(nextMap);
stabilizedMap[seatIndex] = _micChangeAuthoritativeSeat(
incomingMic: incomingMic,
recentMic: recentMic,
seatIndex: seatIndex,
clearUser: true,
);
continue;
}
if (incomingUserId == recentUserId) {
confirmedSeatIndexes.add(seatIndex);
continue;
}
stabilizedMap ??= Map<num, MicRes>.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<MicRes> roomWheatList, {
bool notifyIfUnchanged = true,
}) {
final nextMap = _buildMicMap(roomWheatList);
final stabilizedNextMap = _stabilizeMicListSnapshotWithRecentMicChange(
nextMap,
);
_applyMicSnapshot(stabilizedNextMap, notifyIfUnchanged: notifyIfUnchanged);
}
@visibleForTesting
void debugApplyMicListSnapshot(
List<MicRes> 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<num, MicRes> 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 = <String>{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<num, MicRes> _withoutLocallyDismissedRoomMicRelationsFromMicMap(
Map<num, MicRes> 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 <CPRes>[];
return user.copyWith(
cpList: nextCpList,
closeFriendList: nextCloseFriendList,
isCpRelation: effectiveCpList.isNotEmpty,
);
}
List<CPRes>? _withoutLocallyDismissedRoomMicRelationList(
List<CPRes>? 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<String> _roomMicRelationDismissedPairKeysFromRelation(CPRes relation) {
final leftKeys = <String>{
relation.meUserId?.trim() ?? "",
relation.meAccount?.trim() ?? "",
}..remove("");
final rightKeys = <String>{
relation.cpUserId?.trim() ?? "",
relation.cpAccount?.trim() ?? "",
}..remove("");
if (leftKeys.isEmpty || rightKeys.isEmpty) {
return const <String>{};
}
final relationType = scNormalizeCpRelationType(relation.relationType);
return <String>{
for (final leftKey in leftKeys)
for (final rightKey in rightKeys)
_roomMicRelationDismissedPairKey(leftKey, rightKey, relationType),
};
}
String _roomMicRelationDismissedPairKey(
String leftIdentity,
String rightIdentity,
String relationType,
) {
final identities = <String>[leftIdentity.trim(), rightIdentity.trim()]
..sort();
return '${scNormalizeCpRelationType(relationType)}|'
'${identities[0]}|${identities[1]}';
}
Set<String> _roomMicRelationIdentityKeysForUserId(String userId) {
final normalizedUserId = userId.trim();
final keys = <String>{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<bool> _switchRoomRtcToBroadcaster({required bool muted}) async {
Future<bool> _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<void> _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 <String>{_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<void> 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<RtmProvider>(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<RtmProvider>(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,
);
}
}

View File

@ -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<String> _hiddenCurrentRoomRocketLaunchKeys = <String>{};
final Map<String, int> _roomRocketRewardHoldUntilTimes = <String, int>{};
final Map<String, Timer> _roomRocketRewardHoldTimers = <String, Timer>{};
final Set<String> _roomRocketRewardPopupLoadingRoomIds = <String>{};
Timer? _roomRocketRewardPopupRetryTimer;
Timer? _roomRocketRoomReadyRewardRetryTimer;
final Set<String> _shownCpInviteMessageKeys = <String>{};
@ -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 <SCRoomRocketRewardRecordRes>[];
final records =
initialRoomRecords.isNotEmpty
? List<SCRoomRocketRewardRecordRes>.unmodifiable(initialRoomRecords)
: existing?.initialRoomRecords ??
const <SCRoomRocketRewardRecordRes>[];
? _mergeRoomRocketRewardRecords(existingRecords, initialRoomRecords)
: existingRecords;
_pendingRoomRocketRewardPopups[normalizedRoomId] =
_PendingRoomRocketRewardPopup(
roomId: normalizedRoomId,
@ -4023,6 +4052,47 @@ class RealTimeMessagingManager extends ChangeNotifier {
);
}
List<SCRoomRocketRewardRecordRes> _mergeRoomRocketRewardRecords(
List<SCRoomRocketRewardRecordRes> existingRecords,
List<SCRoomRocketRewardRecordRes> incomingRecords,
) {
if (existingRecords.isEmpty) {
return List<SCRoomRocketRewardRecordRes>.unmodifiable(incomingRecords);
}
if (incomingRecords.isEmpty) {
return existingRecords;
}
final merged = <SCRoomRocketRewardRecordRes>[...existingRecords];
final seen = existingRecords.map(_roomRocketRewardRecordCacheKey).toSet();
for (final record in incomingRecords) {
if (seen.add(_roomRocketRewardRecordCacheKey(record))) {
merged.add(record);
}
}
return List<SCRoomRocketRewardRecordRes>.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<SCRoomRocketRewardRecordRes> initialRoomRecords =
const <SCRoomRocketRewardRecordRes>[],
}) 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 <String>{_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 <List<SCRoomRocketRewardRecordRes>>[
<SCRoomRocketRewardRecordRes>[],
];
_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<List<SCRoomRocketRewardRecordRes>> popupGroups,
required int groupIndex,
required List<SCRoomRocketRewardRecordRes> 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 <String>{_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<void>.delayed(const Duration(milliseconds: 160), () {
_showRoomRocketRewardPopupGroups(
roomId: roomId,
popupGroups: popupGroups,
groupIndex: nextGroupIndex,
initialRoomRecords: initialRoomRecords,
userProfileResolver: userProfileResolver,
);
}),
);
}
},
),
),
);
}
void _scheduleRoomRocketRewardPopupRetry(
String roomId,
int attempt, {

View File

@ -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 (_) {

View File

@ -180,6 +180,17 @@ class SocialChatUserProfileManager extends ChangeNotifier {
}
final cpPairs = <CPRes>[];
final closeFriendPairs = <CPRes>[];
_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<CPRes> cpPairs,
required List<CPRes> closeFriendPairs,
required List<CPRes>? relations,
String? fallbackRelationType,
}) {
for (final relation in relations ?? const <CPRes>[]) {
_addCpRelationPair(
cpPairs: cpPairs,
closeFriendPairs: closeFriendPairs,
relation: relation,
fallbackRelationType: fallbackRelationType,
);
}
}
void _addCpRelationPair({
required List<CPRes> cpPairs,
required List<CPRes> 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 = <String>[
relation.meUserId?.trim() ?? "",
relation.cpUserId?.trim() ?? "",
]..removeWhere((value) => value.isEmpty);
if (userIds.length >= 2) {
userIds.sort();
return '$type|id|${userIds.join("|")}';
}
final accounts = <String>[
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<SCCpCabinRes?> _safeLoadCpCabin(
String userId,
String relationType,

View File

@ -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<String, dynamic> 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<String, dynamic> 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;

View File

@ -937,10 +937,21 @@ class SCChatRoomRepository implements SocialChatRoomRepository {
///room/setting/kick-off-microphone
@override
Future<bool> kickOffMicrophone(String roomId, String kickUserId) async {
Future<bool> kickOffMicrophone(
String roomId,
String kickUserId, {
num? mickIndex,
}) async {
final data = <String, dynamic>{
"roomId": roomId,
"kickUserId": kickUserId,
"userId": kickUserId,
if (mickIndex != null) "mickIndex": mickIndex,
if (mickIndex != null) "micIndex": mickIndex,
};
final result = await http.post<bool>(
"a4465894e21be08d833ef701566ffe1b6a835338046b35fb94271445c996f6e3574aff257ce7e668d08f4caccd1c6232",
data: {"roomId": roomId, "kickUserId": kickUserId},
data: data,
fromJson: (json) => json as bool,
);
return result;

View File

@ -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 <String>{
"NONE",
"INACTIVE",
"DISMISSED",
"DISSOLVED",
"CANCELLED",
"CANCELED",
"EXPIRED",
"ENDED",
"REJECTED",
}.contains(normalizedStatus);
}
bool _hasRelationIdentity(SocialChatUserProfile? profile) =>
_profileKeys(profile).isNotEmpty;

View File

@ -29,6 +29,58 @@ class RoomRocketRewardUserProfile {
class RoomRocketApiMapper {
const RoomRocketApiMapper._();
static List<List<SCRoomRocketRewardRecordRes>> rewardPopupRecordGroups(
List<SCRoomRocketRewardRecordRes> records,
) {
if (records.isEmpty) {
return const <List<SCRoomRocketRewardRecordRes>>[];
}
final order = <String>[];
final groups = <String, List<SCRoomRocketRewardRecordRes>>{};
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] = <SCRoomRocketRewardRecordRes>[];
}
groups[key]!.add(record);
}
return order
.map(
(key) => List<SCRoomRocketRewardRecordRes>.unmodifiable(groups[key]!),
)
.toList(growable: false);
}
static List<SCRoomRocketRewardRecordRes> rewardRoomRecordsForPopupGroup(
List<SCRoomRocketRewardRecordRes> roomRecords,
List<SCRoomRocketRewardRecordRes> 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<RoomRocketLevelItem>? levelsFromStatus(
SCRoomRocketStatusRes? status,
) {

View File

@ -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<String> 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<SCRoomRocketRewardRecordRes> _visibleRoomRewardRecords() {
if (_roomRecords.isEmpty) {
return _popupRecords;
@ -217,14 +251,21 @@ class _RoomRocketRewardDialogLoaderState
}
}
String _launchNoFromRecords(List<SCRoomRocketRewardRecordRes> 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<SCRoomRocketRewardRecordRes> records;
final bool hasMore;
}
Set<String> _launchNosFromRecords(List<SCRoomRocketRewardRecordRes> records) {
return records
.map((record) => record.launchNo.trim())
.where((launchNo) => launchNo.isNotEmpty)
.toSet();
}
void _replaceUniqueRewardRecords(

View File

@ -290,7 +290,7 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
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<RoomUserInfoCard> {
}
List<String> _closeFriendProfileData(SocialChatUserProfile? profile) {
final avatars = <String>[];
final brotherAvatars = <String>[];
final sisterAvatars = <String>[];
for (final relation in profile?.closeFriendList ?? const <CPRes>[]) {
final type = _normalizeCpRelationType(relation.relationType);
if (type != "BROTHER" && type != "SISTERS") {
@ -802,10 +803,14 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
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 <String>[...brotherAvatars, ...sisterAvatars];
}
Widget _buildCloseFriendAvatar(String avatarUrl) {
@ -1525,6 +1530,16 @@ class _RoomUserInfoCardState extends State<RoomUserInfoCard> {
partnerUserId,
relationType: cp.relationType,
);
if (context.mounted) {
Provider.of<RtcProvider?>(
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);

View File

@ -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',
});
}

View File

@ -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 {

View File

@ -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', () {

View File

@ -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>[];
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;