yumi-flutter/lib/services/audio/rtc_manager.dart
2026-05-07 10:06:02 +08:00

3909 lines
120 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:yumi/shared/tools/sc_permission_utils.dart';
import 'package:yumi/shared/tools/sc_room_utils.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository_imp.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/services/room/rc_room_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/app/routes/sc_routes.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/mic_res.dart';
import 'package:yumi/shared/business_logic/models/res/follow_room_res.dart'
as follow_room;
import 'package:yumi/shared/business_logic/models/res/my_room_res.dart'
as my_room;
import 'package:yumi/shared/business_logic/models/res/room_res.dart';
import 'package:yumi/modules/room/voice_room_route.dart';
import 'package:yumi/ui_kit/widgets/room/empty_mai_select.dart';
import 'package:yumi/ui_kit/widgets/room/room_user_info_card.dart';
import '../../shared/tools/sc_heartbeat_utils.dart';
import '../../shared/data_sources/models/enum/sc_heartbeat_status.dart';
import '../../shared/data_sources/models/enum/sc_room_info_event_type.dart';
import '../../shared/data_sources/models/enum/sc_room_roles_type.dart';
import '../../shared/business_logic/models/res/sc_is_follow_room_res.dart';
import '../../shared/business_logic/models/res/sc_room_red_packet_list_res.dart';
import '../../shared/business_logic/models/res/sc_room_rocket_status_res.dart';
import '../../shared/business_logic/models/res/sc_room_theme_list_res.dart';
import '../../shared/business_logic/models/res/sc_vip_res.dart';
import '../../shared/tools/sc_room_profile_cache.dart';
import '../../ui_kit/components/sc_float_ichart.dart';
typedef OnSoundVoiceChange = Function(num index, int volum);
typedef RtcProvider = RealTimeCommunicationManager;
typedef RoomMusicMixingStateListener =
void Function(AudioMixingStateType state, AudioMixingReasonType reason);
typedef RoomMusicMicRouteChangedListener = void Function(bool isOnMic);
enum RoomStartupStatus { idle, loading, ready, failed }
enum RoomStartupFailureType { none, entry, im, rtc }
class _RoomStartupAsyncResult<T> {
const _RoomStartupAsyncResult.value(this.value)
: error = null,
stackTrace = null;
const _RoomStartupAsyncResult.error(this.error, this.stackTrace)
: value = null;
final T? value;
final Object? error;
final StackTrace? stackTrace;
T requireValue() {
final capturedError = error;
if (capturedError != null) {
Error.throwWithStackTrace(
capturedError,
stackTrace ?? StackTrace.current,
);
}
return value as T;
}
}
class _AgoraJoinCanceled implements Exception {
const _AgoraJoinCanceled(this.reason);
final String reason;
@override
String toString() => reason;
}
enum _PendingRoomStartupSeatActionType { clickSeat, goUpMic }
class RoomEntryPreviewData {
const RoomEntryPreviewData({
this.roomId,
this.roomAccount,
this.userId,
this.roomCover,
this.roomBackground,
this.roomName,
this.roomDesc,
this.event,
this.sysOrigin,
this.countryCode,
this.countryName,
this.nationalFlag,
this.langCode,
this.del,
this.createTime,
this.updateTime,
this.activeTime,
this.roomSetting,
this.ownerProfile,
this.roomAdminCount,
this.roomMemberCount,
this.entrantsRole,
});
factory RoomEntryPreviewData.fromSocialChatRoom(SocialChatRoomRes room) {
return RoomEntryPreviewData(
roomId: room.id,
roomAccount: room.roomAccount,
userId: room.userId,
roomCover: room.roomCover,
roomBackground: room.roomBackground,
roomName: room.roomName,
roomDesc: room.roomDesc,
event: room.event,
sysOrigin: room.sysOrigin,
countryCode: room.countryCode,
countryName: room.countryName,
nationalFlag: room.nationalFlag,
roomSetting: room.extValues?.roomSetting,
ownerProfile: room.userProfile,
roomAdminCount: room.roomCounter?.adminCount,
roomMemberCount: room.roomCounter?.memberCount,
);
}
factory RoomEntryPreviewData.fromFollowRoom(follow_room.FollowRoomRes room) {
final roomProfile = room.roomProfile;
return RoomEntryPreviewData(
roomId: roomProfile?.id ?? room.id,
roomAccount: roomProfile?.roomAccount,
userId: roomProfile?.userId,
roomCover: roomProfile?.roomCover,
roomBackground: roomProfile?.roomBackground,
roomName: roomProfile?.roomName,
roomDesc: roomProfile?.roomDesc,
event: roomProfile?.event,
sysOrigin: roomProfile?.sysOrigin,
countryCode: roomProfile?.countryCode,
countryName: roomProfile?.countryName,
nationalFlag: roomProfile?.nationalFlag,
roomSetting: roomProfile?.extValues?.roomSetting,
ownerProfile: roomProfile?.userProfile,
roomAdminCount: roomProfile?.roomCounter?.adminCount,
roomMemberCount: roomProfile?.roomCounter?.memberCount,
);
}
factory RoomEntryPreviewData.fromMyRoom(
my_room.MyRoomRes room, {
SocialChatUserProfile? ownerProfile,
}) {
return RoomEntryPreviewData(
roomId: room.id,
roomAccount: room.roomAccount,
userId: room.userId,
roomCover: room.roomCover,
roomBackground: room.roomBackground,
roomName: room.roomName,
roomDesc: room.roomDesc,
event: room.event,
sysOrigin: room.sysOrigin,
countryCode: room.countryCode,
countryName: room.countryName,
nationalFlag: room.nationalFlag,
langCode: room.langCode,
del: room.del,
createTime: room.createTime,
updateTime: room.updateTime,
activeTime: room.activeTime,
roomSetting: room.setting,
ownerProfile: ownerProfile,
roomAdminCount: room.counter?.adminCount,
roomMemberCount: room.counter?.memberCount,
entrantsRole: SCRoomRolesType.HOMEOWNER.name,
);
}
final String? roomId;
final String? roomAccount;
final String? userId;
final String? roomCover;
final String? roomBackground;
final String? roomName;
final String? roomDesc;
final String? event;
final String? sysOrigin;
final String? countryCode;
final String? countryName;
final String? nationalFlag;
final String? langCode;
final bool? del;
final num? createTime;
final num? updateTime;
final num? activeTime;
final RoomSetting? roomSetting;
final SocialChatUserProfile? ownerProfile;
final num? roomAdminCount;
final num? roomMemberCount;
final String? entrantsRole;
int? get seatCount => roomSetting?.mikeSize?.toInt();
JoinRoomRes toJoinRoomRes({String? fallbackRoomId}) {
final resolvedRoomId =
(roomId ?? "").trim().isNotEmpty ? roomId : fallbackRoomId;
return JoinRoomRes(
entrants:
(entrantsRole ?? "").trim().isNotEmpty
? Entrants(roles: entrantsRole)
: null,
roomProfile: RoomProfile(
regionCode: countryCode,
roomCounter:
roomAdminCount != null || roomMemberCount != null
? RoomCounter(
adminCount: roomAdminCount,
memberCount: roomMemberCount,
)
: null,
roomProfile: RoomProfile2(
activeTime: activeTime,
countryCode: countryCode,
countryName: countryName,
createTime: createTime,
del: del,
event: event,
id: resolvedRoomId,
langCode: langCode,
nationalFlag: nationalFlag,
roomAccount: roomAccount,
roomCover: roomCover,
roomBackground: roomBackground,
roomDesc: roomDesc,
roomName: roomName,
sysOrigin: sysOrigin,
updateTime: updateTime,
userId: userId,
),
roomSetting: roomSetting,
userProfile: ownerProfile,
userSVipLevel: ownerProfile?.vipLevel,
),
);
}
}
class _PendingRoomStartupSeatAction {
const _PendingRoomStartupSeatAction.clickSeat({
required this.index,
this.clickUser,
}) : type = _PendingRoomStartupSeatActionType.clickSeat,
eventType = null,
inviterId = null;
const _PendingRoomStartupSeatAction.goUpMic({
required this.index,
this.eventType,
this.inviterId,
}) : type = _PendingRoomStartupSeatActionType.goUpMic,
clickUser = null;
final _PendingRoomStartupSeatActionType type;
final num index;
final SocialChatUserProfile? clickUser;
final String? eventType;
final String? inviterId;
}
class RealTimeCommunicationManager extends ChangeNotifier {
static const Duration _micListPollingInterval = Duration(seconds: 2);
static const Duration _onlineUsersPollingInterval = Duration(seconds: 3);
static const int _exitNetworkRetryLimit = 3;
static const Duration _exitNetworkRetryDelay = Duration(milliseconds: 500);
static const Duration _selfMicStateGracePeriod = Duration(seconds: 4);
static const Duration _giftTriggeredMicRefreshMinInterval = Duration(
milliseconds: 900,
);
static const Duration _agoraJoinTimeout = Duration(seconds: 8);
static const Duration _agoraDisconnectedGracePeriod = Duration(seconds: 12);
static const String _roomStartupSeatLoadingTag =
'roomStartupSeatInteractionLoading';
static const String _selfMicGoUpRetryLoadingTag = 'selfMicGoUpRetryLoading';
static const int _selfMicGoUpFailureLimit = 3;
bool needUpDataUserInfo = false;
bool _roomVisualEffectsEnabled = false;
bool _isExitingCurrentVoiceRoomSession = false;
bool _isHandlingAgoraRoomFailure = false;
bool _agoraJoined = false;
bool _agoraRoleIsBroadcaster = false;
String? _agoraJoinedChannelId;
bool _agoraAudioConfigured = false;
RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle;
RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none;
int _roomStartupFailureToken = 0;
int _roomEntryRequestSerial = 0;
int? _previewRoomSeatCount;
bool _currentRoomIsEntryPreview = false;
bool _isHandlingRoomStartupFailure = false;
Timer? _micListPollingTimer;
Timer? _onlineUsersPollingTimer;
Timer? _joinAgoraTimeoutTimer;
Timer? _agoraDisconnectedCleanupTimer;
Timer? _roomEntryEffectTimer;
Completer<void>? _joinAgoraCompleter;
Future<RtcEngine>? _rtcEngineInitTask;
Future<void>? _rtcEnginePrewarmTask;
Future<void>? _pendingRoomSwitchAgoraLeaveTask;
String? _joiningChannelId;
bool _isRefreshingMicList = false;
bool _isRefreshingOnlineUsers = false;
bool _disableMicListRefreshForCurrentSession = false;
bool _disableOnlineUsersRefreshForCurrentSession = false;
bool _isSelfMicActionInFlight = false;
bool _roomStartupSeatLoadingVisible = false;
bool _selfMicGoUpRetryLoadingVisible = false;
int _selfMicGoUpConsecutiveFailureCount = 0;
_PendingRoomStartupSeatAction? _pendingRoomStartupSeatAction;
bool _pendingMicListRefresh = false;
bool _pendingMicListRefreshNotifyIfUnchanged = false;
Timer? _deferredMicListRefreshTimer;
int _lastMicListRefreshStartedAtMs = 0;
num? _preferredSelfMicIndex;
num? _pendingSelfMicSourceIndex;
int? _pendingSelfMicSwitchGuardUntilMs;
num? _pendingSelfMicReleaseIndex;
int? _pendingSelfMicReleaseGuardUntilMs;
ClientRoleType? _lastAppliedClientRole;
bool? _lastAppliedLocalAudioMuted;
bool? _lastScheduledVoiceLiveOnMic;
String? _lastScheduledVoiceLiveRoomId;
String? _lastScheduledAnchorRoomId;
///当前所在房间
JoinRoomRes? currenRoom;
/// 声音音量变化监听
final List<OnSoundVoiceChange> _onSoundVoiceChangeList = [];
///麦位
Map<num, MicRes> roomWheatMap = {};
final Map<num, int> _seatEmojiEventVersions = {};
int _emojiPlaybackEventVersion = 0;
RtcEngine? engine;
RtcEngineEventHandler? _rtcEngineEventHandler;
RoomMusicMixingStateListener? _roomMusicMixingStateListener;
RoomMusicMicRouteChangedListener? _roomMusicMicRouteChangedListener;
BuildContext? context;
RtmProvider? rtmProvider;
SCIsFollowRoomRes? isFollowRoomRes;
SCRoomRocketStatusRes? roomRocketStatus;
///房间是否静音
bool roomIsMute = false;
bool closeFullGame = false;
///禁音开关 默认关闭
bool isMic = true;
bool shouldShowSeatSoundWave(num index) {
final seat = roomWheatMap[index];
final userId = (seat?.user?.id ?? "").trim();
if (userId.isEmpty || (seat?.micMute ?? false)) {
return false;
}
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (userId == currentUserId && isMic) {
return false;
}
return true;
}
///在线用户列表
List<SocialChatUserProfile> onlineUsers = [];
///房间管理员
List<SocialChatUserProfile> managerUsers = [];
///音乐是否正在播放
bool isMusicPlaying = false;
///房间红包列表
List<SCRoomRedPacketListRes> redPacketList = [];
num roomTaskClaimableCount = 0;
initializeRealTimeCommunicationManager(BuildContext context) {
this.context = context;
}
bool get roomVisualEffectsEnabled => _roomVisualEffectsEnabled;
bool get isExitingCurrentVoiceRoomSession =>
_isExitingCurrentVoiceRoomSession;
RoomStartupStatus get roomStartupStatus => _roomStartupStatus;
RoomStartupFailureType get roomStartupFailureType => _roomStartupFailureType;
int get roomStartupFailureToken => _roomStartupFailureToken;
int? get previewRoomSeatCount => _previewRoomSeatCount;
bool get shouldShowRoomVisualEffects =>
currenRoom != null && _roomVisualEffectsEnabled;
bool get _shouldDeferSeatInteractionForRoomStartup =>
_roomStartupStatus == RoomStartupStatus.loading;
void setRoomVisualEffectsEnabled(bool enabled) {
if (_roomVisualEffectsEnabled == enabled) {
return;
}
if (!enabled) {
_roomEntryEffectTimer?.cancel();
_roomEntryEffectTimer = null;
}
_roomVisualEffectsEnabled = enabled;
notifyListeners();
}
void _setRoomStartupLoading() {
_isHandlingRoomStartupFailure = false;
_roomStartupFailureType = RoomStartupFailureType.none;
if (_roomStartupStatus == RoomStartupStatus.loading) {
return;
}
_roomStartupStatus = RoomStartupStatus.loading;
notifyListeners();
}
void _setRoomStartupReady() {
_isHandlingRoomStartupFailure = false;
_roomStartupFailureType = RoomStartupFailureType.none;
if (_roomStartupStatus == RoomStartupStatus.ready) {
return;
}
_roomStartupStatus = RoomStartupStatus.ready;
notifyListeners();
_finishRoomStartupSeatLoading(executePendingAction: true);
}
void _setRoomStartupFailed(RoomStartupFailureType failureType) {
if (_roomStartupStatus != RoomStartupStatus.loading) {
return;
}
_stopRoomStatePolling();
_isHandlingRoomStartupFailure = false;
_roomStartupFailureType = failureType;
_roomStartupFailureToken += 1;
_roomStartupStatus = RoomStartupStatus.failed;
_finishRoomStartupSeatLoading(executePendingAction: false);
notifyListeners();
}
void _setExitingCurrentVoiceRoomSession(bool enabled, {bool notify = true}) {
if (_isExitingCurrentVoiceRoomSession == enabled) {
return;
}
_isExitingCurrentVoiceRoomSession = enabled;
if (notify) {
notifyListeners();
}
}
void _deferRoomStartupSeatAction(_PendingRoomStartupSeatAction action) {
_pendingRoomStartupSeatAction = action;
_showRoomStartupSeatLoading();
}
void _showRoomStartupSeatLoading() {
if (_roomStartupSeatLoadingVisible) {
return;
}
_roomStartupSeatLoadingVisible = true;
SmartDialog.dismiss(tag: _roomStartupSeatLoadingTag);
SmartDialog.show(
tag: _roomStartupSeatLoadingTag,
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
backType: SmartBackType.block,
clickMaskDismiss: false,
maskColor: Colors.transparent,
builder: (_) {
return Center(
child: Container(
width: 55,
height: 55,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: const Center(child: SCRotatingDotsLoading(size: 28)),
),
);
},
);
}
void _showSelfMicGoUpRetryLoading() {
if (_selfMicGoUpRetryLoadingVisible) {
return;
}
_selfMicGoUpRetryLoadingVisible = true;
SmartDialog.dismiss(tag: _selfMicGoUpRetryLoadingTag);
SmartDialog.show(
tag: _selfMicGoUpRetryLoadingTag,
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
backType: SmartBackType.normal,
clickMaskDismiss: true,
maskColor: Colors.transparent,
onDismiss: () {
_selfMicGoUpRetryLoadingVisible = false;
},
builder: (_) {
return Center(
child: Container(
width: 55,
height: 55,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: const Center(child: SCRotatingDotsLoading(size: 28)),
),
);
},
);
}
void _dismissSelfMicGoUpRetryLoading() {
if (!_selfMicGoUpRetryLoadingVisible) {
return;
}
_selfMicGoUpRetryLoadingVisible = false;
SmartDialog.dismiss(tag: _selfMicGoUpRetryLoadingTag);
}
void _resetSelfMicGoUpFailureState() {
_selfMicGoUpConsecutiveFailureCount = 0;
_dismissSelfMicGoUpRetryLoading();
}
void _handleSelfMicGoUpFailure(String message) {
_selfMicGoUpConsecutiveFailureCount += 1;
if (_selfMicGoUpConsecutiveFailureCount >= _selfMicGoUpFailureLimit) {
SCTts.show(
'Failed to put on the microphone after multiple attempts, please try again later',
);
return;
}
SCTts.show(message);
}
void _finishRoomStartupSeatLoading({required bool executePendingAction}) {
final pendingAction = _pendingRoomStartupSeatAction;
_pendingRoomStartupSeatAction = null;
if (_roomStartupSeatLoadingVisible) {
_roomStartupSeatLoadingVisible = false;
SmartDialog.dismiss(tag: _roomStartupSeatLoadingTag);
}
if (!executePendingAction || pendingAction == null) {
return;
}
Future<void>.microtask(() {
if (_roomStartupStatus != RoomStartupStatus.ready) {
return;
}
switch (pendingAction.type) {
case _PendingRoomStartupSeatActionType.clickSeat:
clickSite(pendingAction.index, clickUser: pendingAction.clickUser);
break;
case _PendingRoomStartupSeatActionType.goUpMic:
shangMai(
pendingAction.index,
eventType: pendingAction.eventType,
inviterId: pendingAction.inviterId,
);
break;
}
});
}
void _startRoomStatePolling() {
_stopRoomStatePolling();
if ((currenRoom?.roomProfile?.roomProfile?.id ?? "").isEmpty) {
return;
}
_micListPollingTimer = Timer.periodic(_micListPollingInterval, (_) {
retrieveMicrophoneList(notifyIfUnchanged: false).catchError((_) {});
});
_onlineUsersPollingTimer = Timer.periodic(_onlineUsersPollingInterval, (_) {
fetchOnlineUsersList(notifyIfUnchanged: false).catchError((_) {});
});
}
void _stopRoomStatePolling() {
_micListPollingTimer?.cancel();
_onlineUsersPollingTimer?.cancel();
_deferredMicListRefreshTimer?.cancel();
_micListPollingTimer = null;
_onlineUsersPollingTimer = null;
_deferredMicListRefreshTimer = null;
_isRefreshingMicList = false;
_isRefreshingOnlineUsers = false;
}
bool _isMissingPathError(Object error) {
final normalizedMessage = _errorMessageFrom(error).toLowerCase();
return normalizedMessage.contains('path was not found') ||
normalizedMessage.contains('the path was not found');
}
String _errorMessageFrom(Object error) {
if (error is DioException) {
final responseData = error.response?.data;
if (responseData is Map) {
final dynamic errorMsg = responseData["errorMsg"];
if (errorMsg != null) {
return errorMsg.toString();
}
}
if ((error.message ?? "").trim().isNotEmpty) {
return error.message!;
}
}
return error.toString();
}
bool _isCancelledRequest(Object error) {
if (error is DioException) {
return error.type == DioExceptionType.cancel ||
(error.error?.toString().trim().toLowerCase() == 'cancel');
}
final normalizedMessage = error.toString().toLowerCase();
return normalizedMessage.contains('dioexceptiontype.cancel') ||
normalizedMessage.contains('error: cancel') ||
normalizedMessage.contains('request was manually cancelled');
}
bool _sameOnlineUsers(
List<SocialChatUserProfile> previous,
List<SocialChatUserProfile> next,
) {
if (previous.length != next.length) {
return false;
}
for (int i = 0; i < previous.length; i++) {
if (!_sameUserProfile(previous[i], next[i])) {
return false;
}
}
return true;
}
bool _sameUserProfile(
SocialChatUserProfile? previous,
SocialChatUserProfile? next,
) {
if (identical(previous, next)) {
return true;
}
if (previous == null || next == null) {
return previous == next;
}
return previous.id == next.id &&
previous.account == next.account &&
previous.userAvatar == next.userAvatar &&
previous.userNickname == next.userNickname &&
previous.userSex == next.userSex &&
previous.roles == next.roles &&
previous.vipLevel == next.vipLevel &&
previous.heartbeatVal == next.heartbeatVal;
}
bool _sameMicMaps(Map<num, MicRes> previous, Map<num, MicRes> next) {
if (previous.length != next.length) {
return false;
}
for (final entry in next.entries) {
if (!_sameMic(previous[entry.key], entry.value)) {
return false;
}
}
return true;
}
bool _sameMic(MicRes? previous, MicRes? next) {
if (identical(previous, next)) {
return true;
}
if (previous == null || next == null) {
return previous == next;
}
return previous.micIndex == next.micIndex &&
previous.micLock == next.micLock &&
previous.micMute == next.micMute &&
_sameUserProfile(previous.user, next.user);
}
void _refreshManagerUsers(List<SocialChatUserProfile> users) {
managerUsers
..clear()
..addAll(users.where((user) => user.roles == SCRoomRolesType.ADMIN.name));
}
Map<num, MicRes> _buildMicMap(List<MicRes> roomWheatList) {
final previousMap = roomWheatMap;
final nextMap = <num, MicRes>{};
final userSeatMap = <String, num>{};
for (final roomWheat in roomWheatList) {
final micIndex = roomWheat.micIndex;
if (micIndex == null) {
continue;
}
final previousMic = previousMap[micIndex];
final shouldPreserveTransientState =
previousMic?.user?.id == roomWheat.user?.id &&
previousMic?.user?.id != null;
final mergedMic =
shouldPreserveTransientState
? roomWheat.copyWith(
emojiPath:
(roomWheat.emojiPath ?? "").isNotEmpty
? roomWheat.emojiPath
: previousMic?.emojiPath,
type:
(roomWheat.type ?? "").isNotEmpty
? roomWheat.type
: previousMic?.type,
number:
(roomWheat.number ?? "").isNotEmpty
? roomWheat.number
: previousMic?.number,
)
: roomWheat;
final hydratedMic = _mergeKnownVipIntoMic(mergedMic);
final normalizedUserId = (hydratedMic.user?.id ?? "").trim();
if (normalizedUserId.isNotEmpty) {
final existingIndex = userSeatMap[normalizedUserId];
if (existingIndex != null) {
final shouldReplaceExistingSeat = _shouldPreferDuplicateMicSeat(
userId: normalizedUserId,
existingIndex: existingIndex,
candidateIndex: micIndex,
previousMap: previousMap,
);
if (!shouldReplaceExistingSeat) {
continue;
}
nextMap.remove(existingIndex);
}
userSeatMap[normalizedUserId] = micIndex;
}
nextMap[micIndex] = hydratedMic;
}
return _stabilizeSelfMicSnapshot(nextMap, previousMap: previousMap);
}
void _startSelfMicSwitchGuard({
required num sourceIndex,
required num targetIndex,
}) {
_clearSelfMicReleaseGuard();
_preferredSelfMicIndex = targetIndex;
_pendingSelfMicSourceIndex = sourceIndex;
_pendingSelfMicSwitchGuardUntilMs =
DateTime.now().add(_selfMicStateGracePeriod).millisecondsSinceEpoch;
}
void _clearSelfMicSwitchGuard({bool clearPreferredIndex = false}) {
_pendingSelfMicSourceIndex = null;
_pendingSelfMicSwitchGuardUntilMs = null;
if (clearPreferredIndex) {
_preferredSelfMicIndex = null;
}
}
void _startSelfMicReleaseGuard({required num sourceIndex}) {
_pendingSelfMicReleaseIndex = sourceIndex;
_pendingSelfMicReleaseGuardUntilMs =
DateTime.now().add(_selfMicStateGracePeriod).millisecondsSinceEpoch;
}
void _clearSelfMicReleaseGuard() {
_pendingSelfMicReleaseIndex = null;
_pendingSelfMicReleaseGuardUntilMs = null;
}
bool _shouldSuppressCurrentUserOnSeat(num index, {MicRes? seat}) {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return false;
}
final releaseIndex = _pendingSelfMicReleaseIndex;
final releaseGuardUntilMs = _pendingSelfMicReleaseGuardUntilMs ?? 0;
if (releaseIndex != index ||
releaseGuardUntilMs <= DateTime.now().millisecondsSinceEpoch) {
return false;
}
final resolvedSeat = seat ?? roomWheatMap[index];
return (resolvedSeat?.user?.id ?? "").trim() == currentUserId;
}
MicRes? micAtIndexForDisplay(num index) {
final seat = roomWheatMap[index];
if (!_shouldSuppressCurrentUserOnSeat(index, seat: seat)) {
return seat;
}
return seat?.copyWith(clearUser: true);
}
int emojiEventVersionForSeat(num index) {
return _seatEmojiEventVersions[index] ?? 0;
}
void consumeSeatEmojiEvent(num index, int eventVersion) {
if (emojiEventVersionForSeat(index) != eventVersion) {
return;
}
_seatEmojiEventVersions.remove(index);
roomWheatMap[index]?.setEmojiPath = null;
}
Map<num, MicRes> _stabilizeSelfMicSnapshot(
Map<num, MicRes> nextMap, {
required Map<num, MicRes> previousMap,
}) {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return nextMap;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final targetIndex = _preferredSelfMicIndex;
final sourceIndex = _pendingSelfMicSourceIndex;
final guardUntilMs = _pendingSelfMicSwitchGuardUntilMs ?? 0;
final hasActiveGuard =
targetIndex != null && sourceIndex != null && guardUntilMs > nowMs;
final selfSeatIndices =
nextMap.entries
.where((entry) => entry.value.user?.id == currentUserId)
.map((entry) => entry.key)
.toList();
if (targetIndex != null &&
nextMap[targetIndex]?.user?.id == currentUserId) {
_clearSelfMicSwitchGuard();
return nextMap;
}
if (!hasActiveGuard) {
_clearSelfMicSwitchGuard();
} else {
final resolvedTargetIndex = targetIndex;
final resolvedSourceIndex = sourceIndex;
final selfOnlyOnSource =
selfSeatIndices.isEmpty ||
selfSeatIndices.every(
(seatIndex) => seatIndex == resolvedSourceIndex,
);
if (selfOnlyOnSource) {
final optimisticTargetSeat = previousMap[resolvedTargetIndex];
if (optimisticTargetSeat != null &&
optimisticTargetSeat.user?.id == currentUserId) {
final incomingTargetSeat = nextMap[resolvedTargetIndex];
if (incomingTargetSeat?.user == null ||
incomingTargetSeat?.user?.id == currentUserId) {
final stabilizedMap = Map<num, MicRes>.from(nextMap);
for (final seatIndex in selfSeatIndices) {
if (seatIndex == resolvedTargetIndex) {
continue;
}
final seat = stabilizedMap[seatIndex];
if (seat == null) {
continue;
}
stabilizedMap[seatIndex] = seat.copyWith(clearUser: true);
}
final baseSeat = incomingTargetSeat ?? optimisticTargetSeat;
stabilizedMap[resolvedTargetIndex] = baseSeat.copyWith(
user: optimisticTargetSeat.user,
micMute:
incomingTargetSeat?.micMute ?? optimisticTargetSeat.micMute,
micLock:
incomingTargetSeat?.micLock ?? optimisticTargetSeat.micLock,
roomToken:
incomingTargetSeat?.roomToken ??
optimisticTargetSeat.roomToken,
emojiPath:
(incomingTargetSeat?.emojiPath ?? "").isNotEmpty
? incomingTargetSeat?.emojiPath
: optimisticTargetSeat.emojiPath,
type:
(incomingTargetSeat?.type ?? "").isNotEmpty
? incomingTargetSeat?.type
: optimisticTargetSeat.type,
number:
(incomingTargetSeat?.number ?? "").isNotEmpty
? incomingTargetSeat?.number
: optimisticTargetSeat.number,
);
return stabilizedMap;
}
}
}
}
final releaseIndex = _pendingSelfMicReleaseIndex;
final releaseGuardUntilMs = _pendingSelfMicReleaseGuardUntilMs ?? 0;
final hasActiveReleaseGuard =
releaseIndex != null && releaseGuardUntilMs > nowMs;
if (!hasActiveReleaseGuard) {
_clearSelfMicReleaseGuard();
return nextMap;
}
if (selfSeatIndices.isEmpty) {
return nextMap;
}
if (selfSeatIndices.any((seatIndex) => seatIndex != releaseIndex)) {
_clearSelfMicReleaseGuard();
return nextMap;
}
final releaseSeat = nextMap[releaseIndex];
if (releaseSeat?.user?.id != currentUserId) {
_clearSelfMicReleaseGuard();
return nextMap;
}
final stabilizedMap = Map<num, MicRes>.from(nextMap);
stabilizedMap[releaseIndex] = releaseSeat!.copyWith(clearUser: true);
return stabilizedMap;
}
bool _shouldPreferDuplicateMicSeat({
required String userId,
required num existingIndex,
required num candidateIndex,
required Map<num, MicRes> previousMap,
}) {
if (existingIndex == candidateIndex) {
return true;
}
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (userId == currentUserId && _preferredSelfMicIndex != null) {
if (candidateIndex == _preferredSelfMicIndex) {
return true;
}
if (existingIndex == _preferredSelfMicIndex) {
return false;
}
}
final candidateWasPreviouslyOccupiedByUser =
previousMap[candidateIndex]?.user?.id == userId;
final existingWasPreviouslyOccupiedByUser =
previousMap[existingIndex]?.user?.id == userId;
if (candidateWasPreviouslyOccupiedByUser !=
existingWasPreviouslyOccupiedByUser) {
return candidateWasPreviouslyOccupiedByUser;
}
return false;
}
bool _isCurrentAgoraChannel(String? channelId) {
final normalizedChannelId = (channelId ?? "").trim();
final currentRoomId =
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (normalizedChannelId.isEmpty) {
return false;
}
return normalizedChannelId == currentRoomId ||
normalizedChannelId == (_joiningChannelId ?? "").trim();
}
void _cancelAgoraJoinWaiter([Object? error]) {
_joinAgoraTimeoutTimer?.cancel();
_joinAgoraTimeoutTimer = null;
final completer = _joinAgoraCompleter;
_joinAgoraCompleter = null;
if (error != null && completer != null && !completer.isCompleted) {
completer.completeError(error);
}
}
void _cancelAgoraDisconnectedCleanup() {
_agoraDisconnectedCleanupTimer?.cancel();
_agoraDisconnectedCleanupTimer = null;
}
void _resetAgoraTracking({bool clearJoiningChannel = true}) {
_cancelAgoraJoinWaiter(const _AgoraJoinCanceled('Agora join canceled'));
_cancelAgoraDisconnectedCleanup();
_agoraJoined = false;
_agoraRoleIsBroadcaster = false;
_agoraJoinedChannelId = null;
if (clearJoiningChannel) {
_joiningChannelId = null;
}
_resetLocalAudioRuntimeTracking();
}
bool get _canReportMicActive {
return _agoraJoined && _agoraRoleIsBroadcaster && isOnMai();
}
bool get _joinAgoraCompleterIsPending {
final completer = _joinAgoraCompleter;
return completer != null && !completer.isCompleted;
}
void _resyncHeartbeatFromAgoraState() {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
final canReportMicActive = _canReportMicActive;
_syncRoomHeartbeatState(
roomId: roomId,
isOnMic: canReportMicActive,
shouldRunAnchorHeartbeat: canReportMicActive,
);
}
bool _isFatalAgoraError(ErrorCodeType err) {
return err == ErrorCodeType.errInvalidArgument ||
err == ErrorCodeType.errNotReady ||
err == ErrorCodeType.errNotSupported ||
err == ErrorCodeType.errRefused ||
err == ErrorCodeType.errInvalidState ||
err == ErrorCodeType.errNoPermission ||
err == ErrorCodeType.errJoinChannelRejected ||
err == ErrorCodeType.errInvalidAppId ||
err == ErrorCodeType.errInvalidChannelName ||
err == ErrorCodeType.errTokenExpired ||
err == ErrorCodeType.errInvalidToken ||
err == ErrorCodeType.errConnectionLost;
}
bool _isFatalConnectionReason(ConnectionChangedReasonType reason) {
return reason ==
ConnectionChangedReasonType.connectionChangedBannedByServer ||
reason == ConnectionChangedReasonType.connectionChangedJoinFailed ||
reason == ConnectionChangedReasonType.connectionChangedInvalidAppId ||
reason ==
ConnectionChangedReasonType.connectionChangedInvalidChannelName ||
reason == ConnectionChangedReasonType.connectionChangedInvalidToken ||
reason == ConnectionChangedReasonType.connectionChangedTokenExpired ||
reason ==
ConnectionChangedReasonType.connectionChangedRejectedByServer ||
reason == ConnectionChangedReasonType.connectionChangedSameUidLogin ||
reason ==
ConnectionChangedReasonType.connectionChangedTooManyBroadcasters ||
reason ==
ConnectionChangedReasonType
.connectionChangedLicenseValidationFailure;
}
void _syncSelfMicRuntimeState() {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
if ((currentUserId ?? "").isEmpty) {
return;
}
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
MicRes? currentUserMic;
for (final mic in roomWheatMap.values) {
if (mic.user?.id == currentUserId) {
currentUserMic = mic;
break;
}
}
if (currentUserMic == null) {
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
_agoraRoleIsBroadcaster = false;
_syncRoomHeartbeatState(
roomId: roomId,
isOnMic: false,
shouldRunAnchorHeartbeat: false,
);
_applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleAudience,
muted: true,
);
_roomMusicMicRouteChangedListener?.call(false);
return;
}
_preferredSelfMicIndex = currentUserMic.micIndex;
final canReportMicActive = _canReportMicActive;
_syncRoomHeartbeatState(
roomId: roomId,
isOnMic: canReportMicActive,
shouldRunAnchorHeartbeat: canReportMicActive,
);
_applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleBroadcaster,
muted: (currentUserMic.micMute ?? false) || isMic,
);
_roomMusicMicRouteChangedListener?.call(true);
}
void _syncRoomHeartbeatState({
required String roomId,
required bool isOnMic,
required bool shouldRunAnchorHeartbeat,
}) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
_resetHeartbeatTracking();
return;
}
if (!_agoraJoined) {
if (_lastScheduledVoiceLiveRoomId != null ||
_lastScheduledAnchorRoomId != null) {
SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.ONLINE.name,
false,
);
SCHeartbeatUtils.cancelAnchorTimer();
}
_resetHeartbeatTracking();
return;
}
final voiceLiveChanged =
_lastScheduledVoiceLiveRoomId != normalizedRoomId ||
_lastScheduledVoiceLiveOnMic != isOnMic;
if (voiceLiveChanged) {
SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.VOICE_LIVE.name,
isOnMic,
roomId: normalizedRoomId,
);
_lastScheduledVoiceLiveRoomId = normalizedRoomId;
_lastScheduledVoiceLiveOnMic = isOnMic;
}
if (!shouldRunAnchorHeartbeat) {
if (_lastScheduledAnchorRoomId != null) {
SCHeartbeatUtils.cancelAnchorTimer();
_lastScheduledAnchorRoomId = null;
}
return;
}
if (voiceLiveChanged || _lastScheduledAnchorRoomId != normalizedRoomId) {
SCHeartbeatUtils.scheduleAnchorHeartbeat(normalizedRoomId);
_lastScheduledAnchorRoomId = normalizedRoomId;
}
}
Future<void> _bootstrapRoomHeartbeatState() async {
final String roomId =
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (roomId.isEmpty) {
_resetHeartbeatTracking();
return;
}
await SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.VOICE_LIVE.name,
false,
roomId: roomId,
);
_lastScheduledVoiceLiveRoomId = roomId;
_lastScheduledVoiceLiveOnMic = false;
if (_lastScheduledAnchorRoomId != null) {
SCHeartbeatUtils.cancelAnchorTimer();
_lastScheduledAnchorRoomId = null;
}
}
void _applyLocalAudioRuntimeState({
required ClientRoleType clientRole,
required bool muted,
}) {
if (clientRole != ClientRoleType.clientRoleBroadcaster) {
_agoraRoleIsBroadcaster = false;
}
if (engine == null) {
_lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null;
return;
}
if (_lastAppliedClientRole != clientRole) {
engine?.setClientRole(role: clientRole);
_lastAppliedClientRole = clientRole;
}
if (!muted && _lastAppliedLocalAudioMuted != false) {
adjustRecordingSignalVolume(100);
}
if (_lastAppliedLocalAudioMuted != muted) {
engine?.muteLocalAudioStream(muted);
_lastAppliedLocalAudioMuted = muted;
}
}
void _resetHeartbeatTracking() {
_lastScheduledVoiceLiveOnMic = null;
_lastScheduledVoiceLiveRoomId = null;
_lastScheduledAnchorRoomId = null;
}
void _resetLocalAudioRuntimeTracking() {
_lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null;
}
void _applyMicSnapshot(
Map<num, MicRes> nextMap, {
bool notifyIfUnchanged = true,
}) {
final changed = !_sameMicMaps(roomWheatMap, nextMap);
roomWheatMap = nextMap;
_syncSelfMicRuntimeState();
if (changed || notifyIfUnchanged) {
notifyListeners();
}
}
int? _tryParseAgoraUid(String? value) {
final normalizedValue = (value ?? '').trim();
if (normalizedValue.isEmpty) {
return null;
}
return int.tryParse(normalizedValue);
}
int _stableAgoraUidFromUser(SocialChatUserProfile? user) {
final source =
'${user?.account ?? ""}|${user?.id ?? ""}|${user?.userNickname ?? ""}';
var hash = 0x811C9DC5;
for (final codeUnit in source.codeUnits) {
hash ^= codeUnit;
hash = (hash * 0x01000193) & 0x7fffffff;
}
return hash == 0 ? 1 : hash;
}
int _resolveAgoraUidForUser(SocialChatUserProfile? user) {
final parsedAccountUid = _tryParseAgoraUid(user?.account);
if (parsedAccountUid != null && parsedAccountUid > 0) {
return parsedAccountUid;
}
final parsedUserIdUid = _tryParseAgoraUid(user?.id);
if (parsedUserIdUid != null && parsedUserIdUid > 0) {
return parsedUserIdUid;
}
return _stableAgoraUidFromUser(user);
}
int _resolveAgoraUidForCurrentUser() {
return _resolveAgoraUidForUser(
AccountStorage().getCurrentUser()?.userProfile,
);
}
void requestGiftTriggeredMicRefresh() {
requestMicrophoneListRefresh(
notifyIfUnchanged: false,
minInterval: _giftTriggeredMicRefreshMinInterval,
);
}
void requestMicrophoneListRefresh({
bool notifyIfUnchanged = false,
Duration minInterval = Duration.zero,
}) {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty || _disableMicListRefreshForCurrentSession) {
return;
}
if (minInterval <= Duration.zero) {
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = null;
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
return;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final elapsedMs = nowMs - _lastMicListRefreshStartedAtMs;
if (!_isRefreshingMicList && elapsedMs >= minInterval.inMilliseconds) {
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = null;
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
return;
}
final remainingMs = minInterval.inMilliseconds - elapsedMs;
final delayMs = remainingMs > 80 ? remainingMs : 80;
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = Timer(Duration(milliseconds: delayMs), () {
_deferredMicListRefreshTimer = null;
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
});
}
Future<void> _cleanupAgoraRoomState({
bool clearMicSeats = false,
String? onlyIfJoinedChannelId,
}) async {
final expectedChannelId = (onlyIfJoinedChannelId ?? "").trim();
if (expectedChannelId.isNotEmpty) {
final joinedChannelId = (_agoraJoinedChannelId ?? "").trim();
final currentRoomId =
(currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (joinedChannelId.isNotEmpty && joinedChannelId != expectedChannelId) {
return;
}
if (joinedChannelId.isEmpty &&
currentRoomId.isNotEmpty &&
currentRoomId != expectedChannelId) {
return;
}
}
_resetAgoraTracking();
try {
await engine?.leaveChannel();
} catch (error) {
debugPrint('[Agora] leave channel during cleanup failed: $error');
}
if (clearMicSeats) {
roomWheatMap.clear();
notifyListeners();
}
_resyncHeartbeatFromAgoraState();
}
Future<void> _leaveAgoraRoomForExit() async {
final rtcEngine = engine;
_resetAgoraTracking();
if (rtcEngine == null) {
return;
}
try {
await rtcEngine.leaveChannel();
} catch (error) {
debugPrint('[RoomExit] leave Agora channel failed: $error');
}
}
Future<void> _handleAgoraJoinFailed(
Object error, {
required bool exitRoom,
}) async {
debugPrint('[Agora] join failed: $error');
await _cleanupAgoraRoomState(clearMicSeats: true);
if (!exitRoom || _isExitingCurrentVoiceRoomSession) {
return;
}
SCTts.show("Join room fail");
unawaited(exitCurrentVoiceRoomSession(false));
}
Future<void> _handleAgoraDisconnected(Object reason) async {
if (_isHandlingAgoraRoomFailure || _isExitingCurrentVoiceRoomSession) {
return;
}
_isHandlingAgoraRoomFailure = true;
debugPrint('[Agora] disconnected, leave room: $reason');
SCTts.show("Voice connection failed");
await _cleanupAgoraRoomState(clearMicSeats: true);
await exitCurrentVoiceRoomSession(false);
}
void _scheduleAgoraDisconnectedCleanup(Object reason) {
if (_isExitingCurrentVoiceRoomSession) {
return;
}
_agoraDisconnectedCleanupTimer?.cancel();
_agoraDisconnectedCleanupTimer = Timer(_agoraDisconnectedGracePeriod, () {
_agoraDisconnectedCleanupTimer = null;
if (!_agoraJoined && !_isExitingCurrentVoiceRoomSession) {
unawaited(_handleAgoraDisconnected(reason));
}
});
}
Future<void> prewarmRtcEngine() {
final existingTask = _rtcEnginePrewarmTask;
if (existingTask != null) {
return existingTask;
}
final task = _prewarmRtcEngine();
_rtcEnginePrewarmTask = task;
return task;
}
Future<void> _prewarmRtcEngine() async {
try {
final rtcEngine = await _initAgoraRtcEngine();
await _configureAgoraAudioEngine(rtcEngine);
} catch (error, stackTrace) {
_rtcEnginePrewarmTask = null;
debugPrint('[Agora] prewarm failed: $error\n$stackTrace');
rethrow;
}
}
Future<String> _fetchRtcTokenForCurrentRoom({
required bool isPublisher,
}) async {
final channelId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
final userId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
final rtcToken = await SCAccountRepository().getRtcToken(
channelId,
userId,
isPublisher: isPublisher,
);
return rtcToken.rtcToken ?? "";
}
Future<_RoomStartupAsyncResult<T>> _captureStartupFuture<T>(
Future<T> future,
) async {
try {
return _RoomStartupAsyncResult<T>.value(await future);
} catch (error, stackTrace) {
return _RoomStartupAsyncResult<T>.error(error, stackTrace);
}
}
Future<void> joinAgoraVoiceChannel({
bool throwOnError = false,
String? rtcToken,
}) async {
final channelId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim();
if (channelId.isEmpty) {
final error = StateError('Agora channel id is empty');
await _handleAgoraJoinFailed(error, exitRoom: !throwOnError);
if (throwOnError) {
throw error;
}
return;
}
final pendingRoomSwitchLeaveTask = _pendingRoomSwitchAgoraLeaveTask;
if (pendingRoomSwitchLeaveTask != null) {
try {
await pendingRoomSwitchLeaveTask;
} catch (error) {
debugPrint('[Agora] pending room switch leave failed: $error');
}
}
_cancelAgoraJoinWaiter(const _AgoraJoinCanceled('Agora join superseded'));
_cancelAgoraDisconnectedCleanup();
_joiningChannelId = channelId;
_agoraJoined = false;
_agoraRoleIsBroadcaster = false;
final joinCompleter = Completer<void>();
_joinAgoraCompleter = joinCompleter;
_joinAgoraTimeoutTimer = Timer(_agoraJoinTimeout, () {
if (!joinCompleter.isCompleted) {
joinCompleter.completeError(
TimeoutException('Agora join timeout', _agoraJoinTimeout),
);
}
});
try {
final rtcEngine = await _initAgoraRtcEngine();
await _configureAgoraAudioEngine(rtcEngine);
final resolvedRtcToken =
rtcToken ?? await _fetchRtcTokenForCurrentRoom(isPublisher: false);
await rtcEngine.joinChannel(
token: resolvedRtcToken,
channelId: channelId,
uid: _resolveAgoraUidForCurrentUser(),
options: const ChannelMediaOptions(
// 自动订阅所有视频流
autoSubscribeVideo: false,
// 自动订阅所有音频流
autoSubscribeAudio: true,
// 发布摄像头采集的视频
publishCameraTrack: false,
// 发布麦克风采集的音频
publishMicrophoneTrack: true,
// 设置用户角色为 clientRoleBroadcaster主播或 clientRoleAudience观众
clientRoleType: ClientRoleType.clientRoleAudience,
channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
),
);
await joinCompleter.future;
_syncSelfMicRuntimeState();
rtcEngine.muteAllRemoteAudioStreams(roomIsMute);
} catch (e) {
if (e is _AgoraJoinCanceled) {
debugPrint('[Agora] join canceled: $e');
if (throwOnError) {
rethrow;
}
return;
}
await _handleAgoraJoinFailed(e, exitRoom: !throwOnError);
if (throwOnError) {
rethrow;
}
debugPrint('加入失败:${e.runtimeType},${e.toString()}');
} finally {
if (identical(_joinAgoraCompleter, joinCompleter)) {
_cancelAgoraJoinWaiter();
}
}
}
Future<bool> _switchAgoraToBroadcaster({
required String publisherToken,
required bool muted,
}) async {
if (!_agoraJoined || engine == null) {
return false;
}
try {
if (publisherToken.trim().isNotEmpty) {
await engine?.renewToken(publisherToken);
}
await engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
if (!muted) {
adjustRecordingSignalVolume(100);
}
await engine?.muteLocalAudioStream(muted);
_lastAppliedClientRole = ClientRoleType.clientRoleBroadcaster;
_lastAppliedLocalAudioMuted = muted;
_agoraRoleIsBroadcaster = true;
_resyncHeartbeatFromAgoraState();
return true;
} catch (error) {
debugPrint('[Agora] switch to broadcaster failed: $error');
_agoraRoleIsBroadcaster = false;
_resyncHeartbeatFromAgoraState();
return false;
}
}
Future<bool> _switchAgoraToAudience() async {
try {
await engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
await engine?.muteLocalAudioStream(true);
_lastAppliedClientRole = ClientRoleType.clientRoleAudience;
_lastAppliedLocalAudioMuted = true;
_agoraRoleIsBroadcaster = false;
_resyncHeartbeatFromAgoraState();
return true;
} catch (error) {
debugPrint('[Agora] switch to audience failed: $error');
return false;
}
}
Future<void> setSelfMicMutedFromBottom(bool muted) async {
isMic = muted;
notifyListeners();
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
final seatIndex = userOnMaiInIndex(currentUserId);
if (seatIndex < 0) {
notifyListeners();
return;
}
final seat = roomWheatMap[seatIndex];
final seatMuted = seat?.micMute ?? false;
if (seatMuted || muted) {
if (_agoraJoined && isOnMai()) {
await _switchAgoraToBroadcaster(
publisherToken: seat?.roomToken ?? "",
muted: true,
);
}
} else {
await _switchAgoraToBroadcaster(
publisherToken: seat?.roomToken ?? "",
muted: false,
);
}
notifyListeners();
}
Future<void> handleSelfMicRemovedByRemote({
bool refreshMicList = true,
}) async {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
await _switchAgoraToAudience();
return;
}
final currentSeatIndex = userOnMaiInIndex(currentUserId);
isMic = true;
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
if (currentSeatIndex > -1) {
_startSelfMicReleaseGuard(sourceIndex: currentSeatIndex);
} else {
_clearSelfMicReleaseGuard();
}
_clearUserFromSeats(currentUserId);
_agoraRoleIsBroadcaster = false;
_syncSelfMicRuntimeState();
notifyListeners();
await _switchAgoraToAudience();
if (refreshMicList) {
requestMicrophoneListRefresh(
notifyIfUnchanged: false,
minInterval: const Duration(milliseconds: 350),
);
}
}
Future<RtcEngine> _initAgoraRtcEngine() {
if (engine != null) {
return Future.value(engine!);
}
final existingTask = _rtcEngineInitTask;
if (existingTask != null) {
return existingTask;
}
final task = _createAgoraRtcEngine();
_rtcEngineInitTask = task;
void clearInitTask() {
if (identical(_rtcEngineInitTask, task)) {
_rtcEngineInitTask = null;
}
}
unawaited(
task.then<void>(
(_) => clearInitTask(),
onError: (_, __) => clearInitTask(),
),
);
return task;
}
Future<RtcEngine> _createAgoraRtcEngine() async {
final rtcEngine = createAgoraRtcEngine();
await rtcEngine.initialize(
RtcEngineContext(appId: SCGlobalConfig.agoraRtcAppid),
);
engine = rtcEngine;
_rtcEngineEventHandler = RtcEngineEventHandler(
onError: (ErrorCodeType err, String msg) {
debugPrint('rtc错误$err');
final error = 'Agora error: $err $msg';
if (!_agoraJoined) {
final completer = _joinAgoraCompleter;
if (completer != null && !completer.isCompleted) {
completer.completeError(StateError(error));
} else {
unawaited(_handleAgoraJoinFailed(error, exitRoom: true));
}
return;
}
if (_isFatalAgoraError(err)) {
unawaited(_handleAgoraDisconnected(error));
}
},
onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
debugPrint('rtc 自己加入 ${connection.channelId} ${connection.localUid}');
final joinedChannelId = connection.channelId ?? "";
if (!_isCurrentAgoraChannel(joinedChannelId)) {
return;
}
_agoraJoined = true;
_agoraRoleIsBroadcaster = false;
_agoraJoinedChannelId = joinedChannelId.trim();
_cancelAgoraDisconnectedCleanup();
_joinAgoraTimeoutTimer?.cancel();
_joinAgoraTimeoutTimer = null;
final completer = _joinAgoraCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete();
}
_resyncHeartbeatFromAgoraState();
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
if (_isCurrentAgoraChannel(connection.channelId)) {
final completer = _joinAgoraCompleter;
if (completer != null && !completer.isCompleted) {
completer.completeError(
StateError('Agora left channel before join success'),
);
}
_resetAgoraTracking();
_resyncHeartbeatFromAgoraState();
}
},
onClientRoleChanged: (
RtcConnection connection,
ClientRoleType oldRole,
ClientRoleType newRole,
ClientRoleOptions newRoleOptions,
) {
if (!_isCurrentAgoraChannel(connection.channelId)) {
return;
}
_agoraRoleIsBroadcaster =
newRole == ClientRoleType.clientRoleBroadcaster;
_resyncHeartbeatFromAgoraState();
},
onClientRoleChangeFailed: (
RtcConnection connection,
ClientRoleChangeFailedReason reason,
ClientRoleType currentRole,
) {
if (!_isCurrentAgoraChannel(connection.channelId)) {
return;
}
_agoraRoleIsBroadcaster =
currentRole == ClientRoleType.clientRoleBroadcaster;
_resyncHeartbeatFromAgoraState();
},
onConnectionStateChanged: (
RtcConnection connection,
ConnectionStateType state,
ConnectionChangedReasonType reason,
) {
if (!_isCurrentAgoraChannel(connection.channelId)) {
return;
}
if (state == ConnectionStateType.connectionStateConnected &&
reason ==
ConnectionChangedReasonType.connectionChangedRejoinSuccess) {
_agoraJoined = true;
_agoraJoinedChannelId = connection.channelId?.trim();
_cancelAgoraDisconnectedCleanup();
_resyncHeartbeatFromAgoraState();
return;
}
if (reason ==
ConnectionChangedReasonType.connectionChangedLeaveChannel) {
_resetAgoraTracking();
_resyncHeartbeatFromAgoraState();
return;
}
if (state == ConnectionStateType.connectionStateFailed ||
_isFatalConnectionReason(reason)) {
_agoraJoined = false;
_agoraRoleIsBroadcaster = false;
_resyncHeartbeatFromAgoraState();
if (_joinAgoraCompleterIsPending) {
_joinAgoraCompleter?.completeError(
StateError('Agora connection failed before join: $reason'),
);
} else {
unawaited(
_handleAgoraDisconnected('Agora connection failed: $reason'),
);
}
return;
}
if (state == ConnectionStateType.connectionStateDisconnected ||
state == ConnectionStateType.connectionStateReconnecting) {
_agoraJoined = false;
_agoraRoleIsBroadcaster = false;
_resyncHeartbeatFromAgoraState();
_scheduleAgoraDisconnectedCleanup(reason);
}
},
onAudioMixingStateChanged: (
AudioMixingStateType state,
AudioMixingReasonType reason,
) {
switch (state) {
case AudioMixingStateType.audioMixingStatePlaying:
isMusicPlaying = true;
break;
case AudioMixingStateType.audioMixingStateStopped:
case AudioMixingStateType.audioMixingStateFailed:
isMusicPlaying = false;
break;
default:
break;
}
_roomMusicMixingStateListener?.call(state, reason);
},
onUserJoined: (connection, remoteUid, elapsed) {
debugPrint('rtc用户 $remoteUid 加入了频道');
},
// 监听远端用户离开
onUserOffline: (connection, remoteUid, reason) {
debugPrint('rtc用户 $remoteUid 离开了频道 (原因: $reason)');
},
onTokenPrivilegeWillExpire: (
RtcConnection connection,
String token,
) async {
var rtcToken = await SCAccountRepository().getRtcToken(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
AccountStorage().getCurrentUser()?.userProfile?.id ?? "",
isPublisher: _agoraRoleIsBroadcaster,
);
engine?.renewToken(rtcToken.rtcToken ?? "");
},
onAudioVolumeIndication: initializeAudioVolumeIndicationCallback,
);
rtcEngine.registerEventHandler(_rtcEngineEventHandler!);
await _configureAgoraAudioEngine(rtcEngine);
return rtcEngine;
}
Future<void> _configureAgoraAudioEngine(RtcEngine rtcEngine) async {
if (_agoraAudioConfigured) {
return;
}
await rtcEngine.setAudioProfile(
profile: AudioProfileType.audioProfileSpeechStandard,
scenario: AudioScenarioType.audioScenarioGameStreaming,
);
await rtcEngine.enableAudioVolumeIndication(
interval: 500,
smooth: 3,
reportVad: true,
);
await rtcEngine.disableVideo();
_agoraAudioConfigured = true;
}
Future<void> releaseRtcEngineForAppTermination() async {
final rtcEngine = engine;
if (rtcEngine == null) {
return;
}
final eventHandler = _rtcEngineEventHandler;
engine = null;
_rtcEngineEventHandler = null;
_rtcEngineInitTask = null;
_rtcEnginePrewarmTask = null;
_agoraAudioConfigured = false;
try {
if (eventHandler != null) {
rtcEngine.unregisterEventHandler(eventHandler);
}
await rtcEngine.leaveChannel();
} catch (e) {
debugPrint('rtc销毁前离开频道出错: $e');
}
_resetAgoraTracking();
try {
await rtcEngine.release();
} catch (e) {
debugPrint('rtc释放引擎出错: $e');
}
}
void initializeAudioVolumeIndicationCallback(
RtcConnection connection,
List<AudioVolumeInfo> speakers,
int speakerNumber,
int totalVolume,
) {
// if (user == null) {
// throw "本地User尚未初始化";
// }
// print('初始化声音监听器:${user.toJson()}');
if (roomWheatMap.isEmpty || speakers.isEmpty) {
return;
}
for (final seat in roomWheatMap.values) {
seat.setVolume = 0;
}
final seatIndexByAgoraUid = <int, num>{};
roomWheatMap.forEach((seatIndex, mic) {
final user = mic.user;
if (user == null) {
return;
}
seatIndexByAgoraUid[_resolveAgoraUidForUser(user)] = seatIndex;
});
final currentUserAgoraUid = _resolveAgoraUidForCurrentUser();
final listeners = List<OnSoundVoiceChange>.from(_onSoundVoiceChangeList);
for (final info in speakers) {
final volume = info.volume ?? 0;
if (volume <= 0) {
continue;
}
final resolvedAgoraUid = info.uid == 0 ? currentUserAgoraUid : info.uid;
final seatIndex = seatIndexByAgoraUid[resolvedAgoraUid];
if (seatIndex == null) {
continue;
}
if (!shouldShowSeatSoundWave(seatIndex)) {
continue;
}
roomWheatMap[seatIndex]?.setVolume = volume;
for (final listener in listeners) {
listener(seatIndex, volume);
}
}
}
///关注/取消房间
void followCurrentVoiceRoom() async {
var result = await SCAccountRepository().followRoom(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
);
if (isFollowRoomRes != null) {
isFollowRoomRes = isFollowRoomRes!.copyWith(
followRoom: !isFollowRoomRes!.followRoom!,
);
} else {
isFollowRoomRes = SCIsFollowRoomRes(followRoom: result);
}
SmartDialog.dismiss(tag: "unFollowDialog");
notifyListeners();
}
int startTime = 0;
String? _preferNonEmpty(String? primary, String? fallback) {
if ((primary ?? "").trim().isNotEmpty) {
return primary;
}
if ((fallback ?? "").trim().isNotEmpty) {
return fallback;
}
return primary ?? fallback;
}
void updateCurrentRoomBasicInfo({
String? roomCover,
String? roomBackground,
String? roomName,
String? roomDesc,
}) {
final currentRoomProfile = currenRoom?.roomProfile?.roomProfile;
if (currenRoom == null || currentRoomProfile == null) {
return;
}
currenRoom = currenRoom?.copyWith(
roomProfile: currenRoom?.roomProfile?.copyWith(
roomProfile: currentRoomProfile.copyWith(
roomCover: _preferNonEmpty(roomCover, currentRoomProfile.roomCover),
roomBackground: _preferNonEmpty(
roomBackground,
currentRoomProfile.roomBackground,
),
roomName: _preferNonEmpty(roomName, currentRoomProfile.roomName),
roomDesc: _preferNonEmpty(roomDesc, currentRoomProfile.roomDesc),
),
),
);
SCRoomProfileCache.saveRoomProfile(
roomId: currentRoomProfile.id ?? "",
roomCover: roomCover,
roomBackground: roomBackground,
roomName: roomName,
roomDesc: roomDesc,
);
notifyListeners();
}
void joinVoiceRoomSession(
BuildContext context,
String roomId, {
String? pwd,
bool clearRoomData = false,
bool needOpenRedenvelope = false,
String redPackId = "",
int? previewSeatCount,
RoomEntryPreviewData? previewData,
}) async {
int nextTime = DateTime.now().millisecondsSinceEpoch;
if (nextTime - startTime < 1000) {
//频繁点击
return;
}
startTime = nextTime;
if (clearRoomData) {
_clearData();
}
bool hasPermission = await SCPermissionUtils.checkMicrophonePermission();
if (!hasPermission) {
SCTts.show('Microphone permission is denied.');
throw ArgumentError('Microphone permission is denied.');
}
if (roomId == currenRoom?.roomProfile?.roomProfile?.id) {
if (_currentRoomIsEntryPreview ||
_roomStartupStatus == RoomStartupStatus.loading) {
SCFloatIchart().remove();
if (!context.mounted) {
return;
}
VoiceRoomRoute.openVoiceRoom(context);
return;
}
///最小化进入房间,或者进入的是同一个房间
final loaded = await loadRoomInfo(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
);
if (!loaded) {
return;
}
if (!context.mounted) {
return;
}
Provider.of<SocialChatUserProfileManager>(
context,
listen: false,
).fetchUserProfileData();
retrieveMicrophoneList();
fetchOnlineUsersList();
_startRoomStatePolling();
_setRoomStartupReady();
setRoomVisualEffectsEnabled(true);
SCFloatIchart().remove();
VoiceRoomRoute.openVoiceRoom(context);
} else {
SCFloatIchart().remove();
if (currenRoom != null) {
_switchAwayFromCurrentRoomForNewEntry();
}
if (!context.mounted) {
return;
}
rtmProvider = Provider.of<RtmProvider>(context, listen: false);
_clearRoomContributionLevelData(context);
rtmProvider?.seedSystemRoomTips(
SCAppLocalizations.of(context)!.systemRoomTips,
);
_setRoomStartupLoading();
setRoomVisualEffectsEnabled(true);
_applyRoomEntryPreview(
roomId,
previewData: previewData,
previewSeatCount: previewSeatCount,
);
VoiceRoomRoute.openVoiceRoom(context);
notifyListeners();
final entryRequestSerial = ++_roomEntryRequestSerial;
unawaited(
_enterAndInitializeVoiceRoomSession(
roomId,
entryRequestSerial: entryRequestSerial,
pwd: pwd,
needOpenRedenvelope: needOpenRedenvelope,
redPackId: redPackId,
),
);
}
}
Future<void> _enterAndInitializeVoiceRoomSession(
String roomId, {
required int entryRequestSerial,
String? pwd,
bool needOpenRedenvelope = false,
String redPackId = "",
}) async {
try {
final enteredRoom = await SCAccountRepository().entryRoom(
roomId,
pwd: pwd,
redPackId: redPackId,
needOpenRedenvelope: needOpenRedenvelope,
);
if (!_isActiveRoomEntryRequest(entryRequestSerial)) {
unawaited(_cleanupStaleEnteredRoom(enteredRoom));
return;
}
currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom);
_syncCurrentUserVipHintFromRoom(currenRoom);
unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial));
_currentRoomIsEntryPreview = false;
_previewRoomSeatCount = null;
notifyListeners();
await initializeRoomSession(
entryRequestSerial: entryRequestSerial,
needOpenRedenvelope: needOpenRedenvelope,
redPackId: redPackId,
shouldOpenRoomPage: false,
);
} catch (e, stackTrace) {
if (!_isActiveRoomEntryRequest(entryRequestSerial)) {
return;
}
debugPrint('[RoomStartup] entry room failed: $e\n$stackTrace');
_setRoomStartupFailed(RoomStartupFailureType.entry);
}
}
JoinRoomRes _mergeVoiceRoomVipHints(
JoinRoomRes enteredRoom,
JoinRoomRes? previewRoom,
) {
final roomProfile = enteredRoom.roomProfile;
if (roomProfile == null) {
return enteredRoom;
}
final enteredOwner = roomProfile.userProfile;
if (_profileHasPositiveVipLevelHint(enteredOwner)) {
return enteredRoom;
}
final roomId = _firstNonBlankText([roomProfile.roomProfile?.id]);
final ownerId = _firstNonBlankText([
roomProfile.roomProfile?.userId,
enteredOwner?.id,
]);
final previewRoomProfile = previewRoom?.roomProfile;
final previewOwner = previewRoomProfile?.userProfile;
final previewRoomId = _firstNonBlankText([
previewRoomProfile?.roomProfile?.id,
]);
final previewOwnerId = _firstNonBlankText([
previewRoomProfile?.roomProfile?.userId,
previewOwner?.id,
]);
final currentProfile = AccountStorage().getCurrentUser()?.userProfile;
SocialChatUserProfile? resolvedOwner = enteredOwner;
String? vipLevel;
final canUsePreviewOwner =
previewOwner != null &&
_sameNonBlankId(roomId, previewRoomId) &&
(ownerId == null || _sameNonBlankId(ownerId, previewOwnerId));
if (canUsePreviewOwner) {
vipLevel = _profileVipLevelHint(previewOwner);
resolvedOwner ??= previewOwner;
}
if (vipLevel == null && _sameNonBlankId(ownerId, currentProfile?.id)) {
vipLevel =
_profileVipLevelHint(currentProfile) ??
_roomVipLevelHintFromEntrants(enteredRoom.entrants);
resolvedOwner ??= currentProfile;
}
if (vipLevel == null || resolvedOwner == null) {
return enteredRoom;
}
return enteredRoom.copyWith(
roomProfile: roomProfile.copyWith(
userProfile: resolvedOwner.copyWith(vipLevel: vipLevel),
userSVipLevel: vipLevel,
),
);
}
void _syncCurrentUserVipHintFromRoom(JoinRoomRes? room) {
final currentUser = AccountStorage().getCurrentUser();
final currentProfile = currentUser?.userProfile;
if (currentUser == null ||
currentProfile == null ||
_profileHasPositiveVipLevelHint(currentProfile)) {
return;
}
final ownerProfile = room?.roomProfile?.userProfile;
final ownerId = _firstNonBlankText([
room?.roomProfile?.roomProfile?.userId,
ownerProfile?.id,
]);
String? vipLevel;
if (_sameNonBlankId(ownerId, currentProfile.id)) {
vipLevel = _profileVipLevelHint(ownerProfile);
}
vipLevel ??= _roomVipLevelHintFromEntrants(room?.entrants);
if (vipLevel == null) {
return;
}
AccountStorage().setCurrentUser(
currentUser.copyWith(
userProfile: currentProfile.copyWith(vipLevel: vipLevel),
),
);
}
Future<void> _refreshCurrentUserVipHintForRoom(int entryRequestSerial) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty) {
return;
}
final currentUser = AccountStorage().getCurrentUser();
final currentProfile = currentUser?.userProfile;
if (currentUser == null || currentProfile == null) {
return;
}
final existingVipLevel = _positiveProfileVipLevelHint(currentProfile);
final vipLevel =
existingVipLevel ?? await _loadCurrentVipLevelForColoredId();
if (vipLevel == null || vipLevel.isEmpty) {
return;
}
final updatedProfile = currentProfile.copyWith(vipLevel: vipLevel);
if (!_profileHasPositiveVipLevelHint(currentProfile)) {
AccountStorage().setCurrentUser(
currentUser.copyWith(userProfile: updatedProfile),
);
}
if (!_isCurrentRoomEntrySession(entryRequestSerial, roomId)) {
return;
}
var changed = false;
final mergedRoom = _mergeKnownVipIntoCurrentRoom(
currenRoom,
updatedProfile,
);
if (!identical(mergedRoom, currenRoom)) {
currenRoom = mergedRoom;
changed = true;
}
final mergedOnlineUsers =
onlineUsers
.map((user) => _mergeKnownVipIntoProfile(user) ?? user)
.toList();
if (!_sameOnlineUsers(onlineUsers, mergedOnlineUsers)) {
onlineUsers = mergedOnlineUsers;
_refreshManagerUsers(onlineUsers);
changed = true;
}
final mergedMicMap = roomWheatMap.map(
(index, mic) => MapEntry(index, _mergeKnownVipIntoMic(mic)),
);
if (!_sameMicMaps(roomWheatMap, mergedMicMap)) {
roomWheatMap = mergedMicMap;
changed = true;
}
if (changed) {
notifyListeners();
}
}
Future<String?> _loadCurrentVipLevelForColoredId() async {
final repository = SCVipRepositoryImp();
try {
final home = await repository.vipHome();
final homeVipLevel = _vipLevelFromStatus(home.state);
if (homeVipLevel != null) {
return homeVipLevel;
}
} catch (_) {}
try {
return _vipLevelFromStatus(await repository.vipStatus());
} catch (_) {
return null;
}
}
String? _vipLevelFromStatus(SCVipStatusRes? status) {
if (status == null) {
return null;
}
if (status.active == false) {
return '0';
}
final level = status.levelInt;
return level > 0 ? level.toString() : '0';
}
JoinRoomRes? _mergeKnownVipIntoCurrentRoom(
JoinRoomRes? room,
SocialChatUserProfile currentProfile,
) {
final roomProfile = room?.roomProfile;
final ownerProfile = roomProfile?.userProfile;
if (room == null || roomProfile == null || ownerProfile == null) {
return room;
}
final ownerId = _firstNonBlankText([
roomProfile.roomProfile?.userId,
ownerProfile.id,
]);
if (!_sameNonBlankId(ownerId, currentProfile.id) ||
_profileHasPositiveVipLevelHint(ownerProfile)) {
return room;
}
final vipLevel = _positiveProfileVipLevelHint(currentProfile);
if (vipLevel == null) {
return room;
}
return room.copyWith(
roomProfile: roomProfile.copyWith(
userProfile: ownerProfile.copyWith(vipLevel: vipLevel),
userSVipLevel: vipLevel,
),
);
}
MicRes _mergeKnownVipIntoMic(MicRes mic) {
final mergedUser = _mergeKnownVipIntoProfile(mic.user);
if (identical(mergedUser, mic.user)) {
return mic;
}
return mic.copyWith(user: mergedUser);
}
SocialChatUserProfile? _mergeKnownVipIntoProfile(
SocialChatUserProfile? profile,
) {
if (profile == null || _profileHasPositiveVipLevelHint(profile)) {
return profile;
}
final currentProfile = AccountStorage().getCurrentUser()?.userProfile;
final currentVipLevel = _positiveProfileVipLevelHint(currentProfile);
if (_sameNonBlankId(profile.id, currentProfile?.id) &&
currentVipLevel != null) {
return profile.copyWith(vipLevel: currentVipLevel);
}
final ownerProfile = currenRoom?.roomProfile?.userProfile;
final ownerVipLevel = _positiveProfileVipLevelHint(ownerProfile);
final ownerId = _firstNonBlankText([
currenRoom?.roomProfile?.roomProfile?.userId,
ownerProfile?.id,
]);
if (_sameNonBlankId(profile.id, ownerId) && ownerVipLevel != null) {
return profile.copyWith(vipLevel: ownerVipLevel);
}
return profile;
}
bool _profileHasPositiveVipLevelHint(SocialChatUserProfile? profile) {
return _positiveProfileVipLevelHint(profile) != null;
}
String? _profileVipLevelHint(SocialChatUserProfile? profile) {
return _firstNonBlankText([profile?.vipLevel]);
}
String? _positiveProfileVipLevelHint(SocialChatUserProfile? profile) {
final vipLevel = _profileVipLevelHint(profile);
if (profile == null || profile.vipLevelForColoredId <= 0) {
return null;
}
return vipLevel ?? profile.vipLevelForColoredId.toString();
}
String? _roomVipLevelHintFromEntrants(Entrants? entrants) {
final abilities = entrants?.nobleVipAbility ?? const <String>[];
var hasAbility = false;
for (final ability in abilities) {
final text = ability.trim();
final normalized = text.toLowerCase();
if (text.isEmpty || normalized == '0' || normalized == 'false') {
continue;
}
hasAbility = true;
if (_looksLikeVipLevelText(text)) {
return text;
}
}
return hasAbility ? '1' : null;
}
bool _looksLikeVipLevelText(String text) {
final plainLevel = int.tryParse(text);
if ((plainLevel ?? 0) > 0) {
return true;
}
if (RegExp(
r'(?:S?VIP|NOBLE[_\s-]*VIP)[^\d]*[1-9]\d*',
caseSensitive: false,
).hasMatch(text)) {
return true;
}
return RegExp(r'[1-9]\d*$').hasMatch(text);
}
String? _firstNonBlankText(Iterable<dynamic> values) {
for (final value in values) {
final text = value?.toString().trim();
if (text != null && text.isNotEmpty) {
return text;
}
}
return null;
}
bool _sameNonBlankId(dynamic left, dynamic right) {
final leftText = left?.toString().trim();
final rightText = right?.toString().trim();
return leftText != null &&
leftText.isNotEmpty &&
rightText != null &&
rightText.isNotEmpty &&
leftText == rightText;
}
bool _isActiveRoomEntryRequest(int entryRequestSerial) {
return entryRequestSerial == _roomEntryRequestSerial &&
_roomStartupStatus == RoomStartupStatus.loading;
}
bool _isCurrentRoomEntrySession(int entryRequestSerial, String roomId) {
return entryRequestSerial == _roomEntryRequestSerial &&
roomId.isNotEmpty &&
currenRoom?.roomProfile?.roomProfile?.id == roomId;
}
bool _isActiveRoomStartup(int entryRequestSerial, String roomId) {
return _isActiveRoomEntryRequest(entryRequestSerial) &&
_isCurrentRoomEntrySession(entryRequestSerial, roomId);
}
void _setPreviewRoomSeatCount(int? seatCount, {bool notify = true}) {
final normalizedSeatCount = _normalizePreviewRoomSeatCount(seatCount);
if (_previewRoomSeatCount == normalizedSeatCount) {
return;
}
_previewRoomSeatCount = normalizedSeatCount;
if (notify) {
notifyListeners();
}
}
int? _normalizePreviewRoomSeatCount(int? seatCount) {
switch (seatCount) {
case 5:
case 10:
case 15:
case 20:
return seatCount;
default:
return null;
}
}
void _applyRoomEntryPreview(
String roomId, {
RoomEntryPreviewData? previewData,
int? previewSeatCount,
}) {
final resolvedPreviewSeatCount = previewData?.seatCount ?? previewSeatCount;
_setPreviewRoomSeatCount(resolvedPreviewSeatCount, notify: false);
if (previewData == null) {
return;
}
currenRoom = previewData.toJoinRoomRes(fallbackRoomId: roomId);
_currentRoomIsEntryPreview = true;
}
Future<void> _cleanupStaleEnteredRoom(JoinRoomRes enteredRoom) async {
final staleRoomId = enteredRoom.roomProfile?.roomProfile?.id ?? "";
if (staleRoomId.isEmpty) {
return;
}
if (_isCurrentRoomId(staleRoomId)) {
debugPrint(
'[RoomStartup] skip stale entry cleanup: room $staleRoomId is active again',
);
return;
}
await _retryExitNetworkRequest('quit stale entered room $staleRoomId', () {
return SCAccountRepository().quitRoom(staleRoomId).then((didQuit) {
if (!didQuit) {
throw StateError('quitRoom returned false');
}
});
});
}
///初始化房间相关
Future<void> initializeRoomSession({
required int entryRequestSerial,
bool needOpenRedenvelope = false,
String? redPackId,
bool shouldOpenRoomPage = true,
}) async {
_disableMicListRefreshForCurrentSession = false;
_disableOnlineUsersRefreshForCurrentSession = false;
final startupRoomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (!_isActiveRoomStartup(entryRequestSerial, startupRoomId)) {
return;
}
if ((currenRoom?.roomProfile?.roomProfile?.event ==
SCRoomInfoEventType.WAITING_CONFIRMED.name ||
currenRoom?.roomProfile?.roomProfile?.event ==
SCRoomInfoEventType.ID_CHANGE.name) &&
currenRoom?.entrants?.roles == SCRoomRolesType.HOMEOWNER.name) {
///需要去修改房间信息,创建群聊
VoiceRoomRoute.openRoomEdit(context!, needRestCurrentRoomInfo: true);
return;
}
SCRoomUtils.roomSCGlobalConfig(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
);
if (currenRoom?.roomProfile?.roomProfile?.id !=
AccountStorage().getCurrentUser()?.userProfile?.id) {
final followRoomId = startupRoomId;
SCChatRoomRepository().isFollowRoom(followRoomId).then((value) {
if (!_isActiveRoomStartup(entryRequestSerial, followRoomId)) {
return;
}
isFollowRoomRes = value;
notifyListeners();
});
}
_setRoomStartupLoading();
setRoomVisualEffectsEnabled(true);
if (shouldOpenRoomPage) {
VoiceRoomRoute.openVoiceRoom(context!);
}
unawaited(
_bootstrapEnteredVoiceRoomSession(
entryRequestSerial: entryRequestSerial,
roomId: startupRoomId,
groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "",
),
);
}
Future<void> _bootstrapEnteredVoiceRoomSession({
required int entryRequestSerial,
required String roomId,
required String groupId,
}) async {
var failureType = RoomStartupFailureType.im;
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
return;
}
try {
final joinGroupFuture = rtmProvider?.joinRoomGroup(groupId, "");
final rtcTokenFuture = _captureStartupFuture(
_fetchRtcTokenForCurrentRoom(isPublisher: false),
);
final microphoneListFuture = retrieveMicrophoneList();
final onlineUsersFuture = fetchOnlineUsersList();
final joinResult = await joinGroupFuture;
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
return;
}
if (joinResult == null || joinResult.code != 0) {
debugPrint(
'[RoomStartup] join IM group failed code=${joinResult?.code} desc=${joinResult?.desc}',
);
_setRoomStartupFailed(RoomStartupFailureType.im);
return;
}
rtmProvider?.seedSystemRoomTips(
SCAppLocalizations.of(context!)!.systemRoomTips,
groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "",
);
rtmProvider?.addMsg(
Msg(
groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "",
msg: currenRoom?.roomProfile?.roomProfile?.roomDesc,
type: SCRoomMsgType.systemTips,
),
);
await Future.wait([microphoneListFuture, onlineUsersFuture]);
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
return;
}
_startRoomStatePolling();
final currentContext = context;
if (currentContext != null && currentContext.mounted) {
Provider.of<SocialChatRoomManager>(
currentContext,
listen: false,
).fetchContributionLevelData(roomId);
}
failureType = RoomStartupFailureType.rtc;
final rtcTokenResult = await rtcTokenFuture;
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
return;
}
final rtcToken = rtcTokenResult.requireValue();
await joinAgoraVoiceChannel(throwOnError: true, rtcToken: rtcToken);
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
await _cleanupAgoraRoomState(
clearMicSeats: currenRoom == null,
onlyIfJoinedChannelId: roomId,
);
return;
}
await _bootstrapRoomHeartbeatState();
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
return;
}
_setRoomStartupReady();
Msg joinMsg = Msg(
groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "",
user: AccountStorage().getCurrentUser()?.userProfile,
msg: "",
role: currenRoom?.entrants?.roles ?? "",
type: SCRoomMsgType.joinRoom,
);
rtmProvider?.dispatchMessage(joinMsg, addLocal: true);
_playRoomEntryEffects(
joinMsg,
entryRequestSerial: entryRequestSerial,
roomId: roomId,
);
_loadRoomSecondaryData();
} catch (error, stackTrace) {
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
debugPrint('[RoomStartup] ignore canceled bootstrap failure: $error');
return;
}
debugPrint('[RoomStartup] bootstrap failed: $error\n$stackTrace');
_setRoomStartupFailed(failureType);
}
}
void _playRoomEntryEffects(
Msg joinMsg, {
required int entryRequestSerial,
required String roomId,
}) {
if (SCGlobalConfig.isEntryVehicleAnimation) {
final sourceUrl =
AccountStorage()
.getCurrentUser()
?.userProfile
?.getMountains()
?.sourceUrl ??
"";
if (sourceUrl.isNotEmpty &&
shouldShowRoomVisualEffects &&
_isCurrentRoomEntrySession(entryRequestSerial, roomId)) {
_roomEntryEffectTimer?.cancel();
_roomEntryEffectTimer = Timer(const Duration(milliseconds: 550), () {
_roomEntryEffectTimer = null;
if (!shouldShowRoomVisualEffects ||
!_isCurrentRoomEntrySession(entryRequestSerial, roomId)) {
return;
}
SCGiftVapSvgaManager().play(
sourceUrl,
priority: SCGiftVapSvgaManager.entryEffectPriority,
type: SCGiftVapSvgaManager.entryEffectType,
);
});
}
}
if (rtmProvider?.msgUserJoinListener != null) {
rtmProvider?.msgUserJoinListener!(joinMsg);
}
}
void _loadRoomSecondaryData() {
SCChatRoomRepository()
.rocketClaim(currenRoom?.roomProfile?.roomProfile?.id ?? "")
.catchError((e) {
return true; // 错误已处理
});
SCChatRoomRepository()
.rocketStatus(currenRoom?.roomProfile?.roomProfile?.id ?? "")
.then((res) {
roomRocketStatus = res;
notifyListeners();
})
.catchError((e) {});
loadRoomRedPacketList(1);
fetchRoomTaskClaimableCount();
}
///更新房间火箭信息
void updateRoomRocketConfigurationStatus(SCRoomRocketStatusRes res) {
roomRocketStatus = res;
notifyListeners();
}
///获取在线用户
Future<void> fetchOnlineUsersList({bool notifyIfUnchanged = true}) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty ||
_isRefreshingOnlineUsers ||
_disableOnlineUsersRefreshForCurrentSession) {
return;
}
_isRefreshingOnlineUsers = true;
try {
final fetchedUsers = await SCChatRoomRepository().roomOnlineUsers(
roomId,
silentErrorToast: true,
);
if (roomId != currenRoom?.roomProfile?.roomProfile?.id) {
return;
}
final mergedUsers =
fetchedUsers.map((user) => _mergeKnownVipIntoProfile(user)!).toList();
final changed = !_sameOnlineUsers(onlineUsers, mergedUsers);
onlineUsers = mergedUsers;
_refreshManagerUsers(onlineUsers);
if (changed || notifyIfUnchanged) {
notifyListeners();
}
} catch (error) {
if (_isMissingPathError(error)) {
_disableOnlineUsersRefreshForCurrentSession = true;
debugPrint(
'[Room][OnlineUsers] suspend auto refresh for current session: ${_errorMessageFrom(error)}',
);
} else {
debugPrint(
'[Room][OnlineUsers] refresh failed: ${_errorMessageFrom(error)}',
);
}
} finally {
_isRefreshingOnlineUsers = false;
}
}
Future<void> retrieveMicrophoneList({bool notifyIfUnchanged = true}) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty || _disableMicListRefreshForCurrentSession) {
return;
}
if (_isRefreshingMicList) {
_pendingMicListRefresh = true;
_pendingMicListRefreshNotifyIfUnchanged =
_pendingMicListRefreshNotifyIfUnchanged || notifyIfUnchanged;
return;
}
_isRefreshingMicList = true;
_lastMicListRefreshStartedAtMs = DateTime.now().millisecondsSinceEpoch;
try {
final roomWheatList = await SCChatRoomRepository().micList(
roomId,
silentErrorToast: true,
);
if (roomId != currenRoom?.roomProfile?.roomProfile?.id) {
return;
}
final nextMap = _buildMicMap(roomWheatList);
_applyMicSnapshot(nextMap, notifyIfUnchanged: notifyIfUnchanged);
} catch (error) {
if (_isMissingPathError(error)) {
_disableMicListRefreshForCurrentSession = true;
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = null;
debugPrint(
'[Room][MicList] suspend auto refresh for current session: ${_errorMessageFrom(error)}',
);
if (notifyIfUnchanged) {
notifyListeners();
}
} else {
debugPrint(
'[Room][MicList] refresh failed: ${_errorMessageFrom(error)}',
);
}
} finally {
_isRefreshingMicList = false;
if (_pendingMicListRefresh) {
_pendingMicListRefresh = false;
final pendingNotifyIfUnchanged =
_pendingMicListRefreshNotifyIfUnchanged;
_pendingMicListRefreshNotifyIfUnchanged = false;
unawaited(
Future<void>.microtask(
() => retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged || pendingNotifyIfUnchanged,
),
),
);
}
}
}
void fetchRoomTaskClaimableCount() {
SCChatRoomRepository()
.roomTaskClaimableCount()
.then((res) {
roomTaskClaimableCount = res.claimableCount ?? 0;
notifyListeners();
})
.catchError((e) {});
}
Future<List<SCRoomRedPacketListRes>> loadRoomRedPacketList(
int current,
) async {
var result = await SCChatRoomRepository().roomRedPacketList(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
current,
);
if (current == 1) {
redPacketList = result;
notifyListeners();
}
return result;
}
Future exitCurrentVoiceRoomSession(
bool isLogout, {
bool deferRemoteCleanup = true,
}) async {
if (_isExitingCurrentVoiceRoomSession) {
return;
}
_setExitingCurrentVoiceRoomSession(true);
_stopRoomStatePolling();
final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "";
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
final isPreviewOnlyExit = _currentRoomIsEntryPreview;
final exitRtmProvider = rtmProvider;
final navigationContext = context;
try {
exitRtmProvider?.msgAllListener = null;
exitRtmProvider?.msgChatListener = null;
exitRtmProvider?.msgGiftListener = null;
SCGiftVapSvgaManager().stopPlayback();
setRoomVisualEffectsEnabled(false);
SCHeartbeatUtils.cancelTimer();
exitRtmProvider?.cleanRoomData();
} catch (e) {
debugPrint('rtc退出房间本地清理出错: $e');
}
_clearData();
if (!isLogout && navigationContext != null) {
SCNavigatorUtils.popUntil(
navigationContext,
ModalRoute.withName(SCRoutes.home),
);
}
final agoraLeaveTask = _leaveAgoraRoomForExit();
final remoteCleanupTask = _cleanupExitedRoomRemotely(
exitRtmProvider: exitRtmProvider,
groupId: isPreviewOnlyExit ? "" : groupId,
roomId: isPreviewOnlyExit ? "" : roomId,
shouldSendOnlineHeartbeat: !isLogout,
);
if (deferRemoteCleanup) {
unawaited(agoraLeaveTask);
unawaited(remoteCleanupTask);
} else {
await agoraLeaveTask;
await remoteCleanupTask;
}
}
void _switchAwayFromCurrentRoomForNewEntry() {
_stopRoomStatePolling();
final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "";
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
final isPreviewOnlyExit = _currentRoomIsEntryPreview;
final exitRtmProvider = rtmProvider;
try {
exitRtmProvider?.msgAllListener = null;
exitRtmProvider?.msgChatListener = null;
exitRtmProvider?.msgGiftListener = null;
SCGiftVapSvgaManager().stopPlayback();
setRoomVisualEffectsEnabled(false);
SCHeartbeatUtils.cancelTimer();
exitRtmProvider?.cleanRoomData();
} catch (e) {
debugPrint('rtc切换房间本地清理出错: $e');
}
_clearData();
final agoraLeaveTask = _leaveAgoraRoomForExit();
_pendingRoomSwitchAgoraLeaveTask = agoraLeaveTask;
unawaited(
agoraLeaveTask.whenComplete(() {
if (identical(_pendingRoomSwitchAgoraLeaveTask, agoraLeaveTask)) {
_pendingRoomSwitchAgoraLeaveTask = null;
}
}),
);
unawaited(
_cleanupExitedRoomRemotely(
exitRtmProvider: exitRtmProvider,
groupId: isPreviewOnlyExit ? "" : groupId,
roomId: isPreviewOnlyExit ? "" : roomId,
shouldSendOnlineHeartbeat: false,
),
);
}
Future<void> _cleanupExitedRoomRemotely({
required RtmProvider? exitRtmProvider,
required String groupId,
required String roomId,
required bool shouldSendOnlineHeartbeat,
}) async {
final tasks = <Future<void>>[];
if (shouldSendOnlineHeartbeat) {
tasks.add(
_retryExitNetworkRequest('heartbeat ONLINE', () async {
if (currenRoom != null) {
debugPrint('[RoomExit] skip ONLINE heartbeat: already in a room');
return;
}
await SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.ONLINE.name,
false,
rethrowOnError: true,
);
}),
);
}
if (groupId.isNotEmpty && exitRtmProvider != null) {
tasks.add(
_retryExitNetworkRequest('quit IM group $groupId', () async {
if (_isCurrentRoomGroup(groupId)) {
debugPrint('[RoomExit] skip quitGroup: re-entered group $groupId');
return;
}
await exitRtmProvider.quitGroup(groupId);
}),
);
}
if (roomId.isNotEmpty) {
tasks.add(
_retryExitNetworkRequest('quit room $roomId', () async {
if (_isCurrentRoomId(roomId)) {
debugPrint('[RoomExit] skip quitRoom: re-entered room $roomId');
return;
}
final didQuit = await SCAccountRepository().quitRoom(roomId);
if (!didQuit) {
throw StateError('quitRoom returned false');
}
}),
);
}
await Future.wait(tasks);
}
Future<void> _retryExitNetworkRequest(
String name,
Future<void> Function() action,
) async {
Object? lastError;
StackTrace? lastStackTrace;
for (var attempt = 1; attempt <= _exitNetworkRetryLimit; attempt++) {
try {
await action();
return;
} catch (error, stackTrace) {
lastError = error;
lastStackTrace = stackTrace;
debugPrint(
'[RoomExit] $name failed attempt '
'$attempt/$_exitNetworkRetryLimit: $error',
);
if (attempt < _exitNetworkRetryLimit) {
await Future.delayed(_exitNetworkRetryDelay * attempt);
}
}
}
debugPrint(
'[RoomExit] $name failed after $_exitNetworkRetryLimit attempts, '
'treat as backend/remote issue: $lastError\n$lastStackTrace',
);
}
bool _isCurrentRoomId(String roomId) {
return roomId.isNotEmpty &&
currenRoom?.roomProfile?.roomProfile?.id == roomId;
}
bool _isCurrentRoomGroup(String groupId) {
return groupId.isNotEmpty &&
currenRoom?.roomProfile?.roomProfile?.roomAccount == groupId;
}
Future<void> resetLocalRoomState({RtmProvider? fallbackRtmProvider}) async {
rtmProvider ??= fallbackRtmProvider;
_stopRoomStatePolling();
try {
SCHeartbeatUtils.cancelTimer();
SCHeartbeatUtils.cancelAnchorTimer();
final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "";
if (groupId.isNotEmpty) {
await rtmProvider?.quitGroup(groupId);
}
await _cleanupAgoraRoomState();
await Future.delayed(const Duration(milliseconds: 100));
} catch (e) {
debugPrint('rtc清理本地房间状态出错: $e');
} finally {
rtmProvider?.cleanRoomData();
_clearData();
notifyListeners();
}
}
Future<void> confirmRoomStartupFailureAndLeave({
BuildContext? navigationContext,
}) async {
if (_isHandlingRoomStartupFailure) {
return;
}
_isHandlingRoomStartupFailure = true;
final failedRoom = currenRoom;
final isPreviewOnlyFailure = _currentRoomIsEntryPreview;
final groupId = failedRoom?.roomProfile?.roomProfile?.roomAccount ?? "";
final roomId = failedRoom?.roomProfile?.roomProfile?.id ?? "";
final navigatorContext = navigationContext ?? context;
final navigator =
navigatorContext != null ? Navigator.maybeOf(navigatorContext) : null;
try {
_stopRoomStatePolling();
SCGiftVapSvgaManager().stopPlayback();
SCHeartbeatUtils.cancelTimer();
SCHeartbeatUtils.cancelAnchorTimer();
rtmProvider?.cleanRoomData();
if (!isPreviewOnlyFailure && groupId.isNotEmpty) {
await rtmProvider?.quitGroup(groupId);
}
await _cleanupAgoraRoomState(clearMicSeats: true);
await Future.delayed(const Duration(milliseconds: 100));
if (!isPreviewOnlyFailure && roomId.isNotEmpty) {
await SCAccountRepository().quitRoom(roomId);
}
} catch (e) {
debugPrint('rtc进房失败清理本地房间状态出错: $e');
} finally {
_clearData();
_isHandlingRoomStartupFailure = false;
notifyListeners();
if (navigator != null && navigator.mounted && navigator.canPop()) {
navigator.pop();
}
}
}
///清空列表数据
void _clearData() {
_roomEntryRequestSerial += 1;
_roomEntryEffectTimer?.cancel();
_roomEntryEffectTimer = null;
_previewRoomSeatCount = null;
_finishRoomStartupSeatLoading(executePendingAction: false);
_resetSelfMicGoUpFailureState();
_clearRoomContributionLevelData();
_stopRoomStatePolling();
_resetHeartbeatTracking();
_resetAgoraTracking();
_disableMicListRefreshForCurrentSession = false;
_disableOnlineUsersRefreshForCurrentSession = false;
_isSelfMicActionInFlight = false;
_roomStartupStatus = RoomStartupStatus.idle;
_roomStartupFailureType = RoomStartupFailureType.none;
_currentRoomIsEntryPreview = false;
_isHandlingRoomStartupFailure = false;
_isHandlingAgoraRoomFailure = false;
_pendingMicListRefresh = false;
_pendingMicListRefreshNotifyIfUnchanged = false;
_seatEmojiEventVersions.clear();
_emojiPlaybackEventVersion = 0;
roomRocketStatus = null;
rtmProvider
?.onNewMessageListenerGroupMap["${currenRoom?.roomProfile?.roomProfile?.roomAccount}"] =
null;
roomWheatMap.clear();
onlineUsers.clear();
managerUsers.clear();
needUpDataUserInfo = false;
SCRoomUtils.roomUsersMap.clear();
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
_clearSelfMicReleaseGuard();
roomIsMute = false;
rtmProvider?.roomAllMsgList.clear();
rtmProvider?.roomChatMsgList.clear();
redPacketList.clear();
currenRoom = null;
_roomVisualEffectsEnabled = false;
_isExitingCurrentVoiceRoomSession = false;
isMic = true;
isMusicPlaying = false;
DataPersistence.setLastTimeRoomId("");
SCGiftVapSvgaManager().stopPlayback();
SCRoomUtils.closeAllDialogs();
SCGlobalConfig.resetVisualEffectSwitchesToRecommendedDefaults();
}
void _clearRoomContributionLevelData([BuildContext? targetContext]) {
final currentContext = targetContext ?? context;
if (currentContext == null || !currentContext.mounted) {
return;
}
Provider.of<SocialChatRoomManager>(
currentContext,
listen: false,
).clearContributionLevelData();
}
@override
void dispose() {
_stopRoomStatePolling();
unawaited(releaseRtcEngineForAppTermination());
super.dispose();
}
void toggleRemoteAudioMuteForAllUsers() {
roomIsMute = !roomIsMute;
engine?.muteAllRemoteAudioStreams(roomIsMute);
notifyListeners();
}
///点击的位置
void clickSite(num index, {SocialChatUserProfile? clickUser}) {
if (_shouldDeferSeatInteractionForRoomStartup) {
_deferRoomStartupSeatAction(
_PendingRoomStartupSeatAction.clickSeat(
index: index,
clickUser: clickUser,
),
);
return;
}
if (_handleDirectSeatInteraction(index, clickUser: clickUser)) {
return;
}
if (index == -1) {
if (clickUser != null) {
if (clickUser.id ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
///是自己,直接打开资料卡
showBottomInCenterDialog(
context!,
RoomUserInfoCard(userId: clickUser.id),
);
} else {
showBottomInBottomDialog(
context!,
EmptyMaiSelect(index: index, clickUser: clickUser),
);
}
}
} else {
SocialChatUserProfile? roomWheatUser = micAtIndexForDisplay(index)?.user;
showBottomInBottomDialog(
context!,
EmptyMaiSelect(index: index, clickUser: roomWheatUser),
);
// if (roomWheatUser != null) {
// ///麦上有人
// if (roomWheatUser.id ==
// AccountStorage().getCurrentUser()?.userProfile?.id) {
// ///是自己
// showBottomInBottomDialog(
// context!,
// EmptyMaiSelect(index: index, clickUser: roomWheatUser),
// );
// } else {
// showBottomInBottomDialog(
// context!,
// RoomUserInfoCard(userId: roomWheatUser.id),
// );
// }
// } else {
// showBottomInBottomDialog(
// context!,
// EmptyMaiSelect(index: index, clickUser: roomWheatUser),
// );
// }
}
}
bool _handleDirectSeatInteraction(
num index, {
SocialChatUserProfile? clickUser,
}) {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
final isRoomAdmin = isFz() || isGL();
if (index == -1) {
if (clickUser == null || (clickUser.id ?? '').isEmpty) {
return false;
}
if (clickUser.id == currentUserId || !isRoomAdmin) {
_openRoomUserInfoCard(clickUser.id);
return true;
}
return false;
}
final seat = micAtIndexForDisplay(index);
final seatUser = seat?.user;
if (seatUser == null) {
if (!(seat?.micLock ?? false) && !isRoomAdmin) {
shangMai(index);
return true;
}
return false;
}
if (seatUser.id == currentUserId) {
if (isRoomAdmin) {
return false;
}
_openRoomUserInfoCard(seatUser.id);
return true;
}
if (!isRoomAdmin && (seatUser.id ?? '').isNotEmpty) {
_openRoomUserInfoCard(seatUser.id);
return true;
}
return false;
}
void _refreshMicListSilently({bool notifyIfUnchanged = false}) {
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
}
void _clearUserFromSeats(String? userId, {num? exceptIndex}) {
final normalizedUserId = (userId ?? "").trim();
if (normalizedUserId.isEmpty) {
return;
}
roomWheatMap.forEach((seatIndex, seat) {
if (seat.user?.id != normalizedUserId || seatIndex == exceptIndex) {
return;
}
roomWheatMap[seatIndex] = seat.copyWith(clearUser: true);
});
}
void _openRoomUserInfoCard(String? userId) {
final normalizedUserId = (userId ?? '').trim();
if (normalizedUserId.isEmpty) {
return;
}
showBottomInCenterDialog(
context!,
RoomUserInfoCard(userId: normalizedUserId),
);
}
addSoundVoiceChangeListener(OnSoundVoiceChange onSoundVoiceChange) {
_onSoundVoiceChangeList.add(onSoundVoiceChange);
debugPrint('添加监听:${_onSoundVoiceChangeList.length}');
}
removeSoundVoiceChangeListener(OnSoundVoiceChange onSoundVoiceChange) {
_onSoundVoiceChangeList.remove(onSoundVoiceChange);
debugPrint('删除监听:${_onSoundVoiceChangeList.length}');
}
void shangMai(num index, {String? eventType, String? inviterId}) async {
if (_shouldDeferSeatInteractionForRoomStartup) {
_deferRoomStartupSeatAction(
_PendingRoomStartupSeatAction.goUpMic(
index: index,
eventType: eventType,
inviterId: inviterId,
),
);
return;
}
var myUser = AccountStorage().getCurrentUser()?.userProfile;
if (currenRoom?.roomProfile?.roomSetting?.touristMike ?? false) {
} else {
if (isTourists()) {
SCTts.show(
SCAppLocalizations.of(context!)!.touristsAreNotAllowedToGoOnTheMic,
);
return;
}
}
final shouldShowRetryLoading = _selfMicGoUpConsecutiveFailureCount > 0;
if (_isSelfMicActionInFlight) {
if (shouldShowRetryLoading) {
_showSelfMicGoUpRetryLoading();
}
return;
}
if (shouldShowRetryLoading) {
_showSelfMicGoUpRetryLoading();
}
_isSelfMicActionInFlight = true;
try {
var micGoUpRes = await SCChatRoomRepository().micGoUp(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
index,
eventType: eventType,
inviterId: inviterId,
);
final targetIndex = micGoUpRes.micIndex ?? index;
final switched = await _switchAgoraToBroadcaster(
publisherToken: micGoUpRes.roomToken ?? "",
muted: (micGoUpRes.micMute ?? false) || isMic,
);
if (!switched) {
await _switchAgoraToAudience();
try {
await SCChatRoomRepository().micGoDown(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
targetIndex,
);
} catch (downError) {
debugPrint(
'[Agora] release backend mic after broadcaster switch failed: $downError',
);
}
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
_clearUserFromSeats(myUser?.id);
_syncSelfMicRuntimeState();
notifyListeners();
_refreshMicListSilently(notifyIfUnchanged: true);
_handleSelfMicGoUpFailure(
'Failed to put on the microphone, Agora switch failed',
);
return;
}
final previousSelfSeatIndex =
myUser != null ? userOnMaiInIndex(myUser.id ?? "") : -1;
final isSeatSwitching =
previousSelfSeatIndex > -1 && previousSelfSeatIndex != targetIndex;
if (myUser != null) {
_clearSelfMicReleaseGuard();
if (isSeatSwitching) {
_startSelfMicSwitchGuard(
sourceIndex: previousSelfSeatIndex,
targetIndex: targetIndex,
);
} else {
_preferredSelfMicIndex = targetIndex;
_clearSelfMicSwitchGuard();
}
_clearUserFromSeats(myUser.id, exceptIndex: targetIndex);
final currentSeat = roomWheatMap[targetIndex];
final currentUser = myUser.copyWith(roles: currenRoom?.entrants?.roles);
roomWheatMap[targetIndex] =
currentSeat?.copyWith(
user: currentUser,
micMute: micGoUpRes.micMute,
micLock: micGoUpRes.micLock,
roomToken: micGoUpRes.roomToken,
) ??
MicRes(
roomId: currenRoom?.roomProfile?.roomProfile?.id,
micIndex: targetIndex,
micLock: micGoUpRes.micLock,
micMute: micGoUpRes.micMute,
user: currentUser,
roomToken: micGoUpRes.roomToken,
);
}
_syncSelfMicRuntimeState();
if (roomWheatMap[targetIndex]?.micMute ?? false) {
///房主上麦自动解禁麦位
if (isFz()) {
await jieJinMai(targetIndex);
}
}
notifyListeners();
_refreshMicListSilently(notifyIfUnchanged: true);
_resetSelfMicGoUpFailureState();
} catch (ex) {
if (_isCancelledRequest(ex)) {
_refreshMicListSilently(notifyIfUnchanged: true);
return;
}
_handleSelfMicGoUpFailure(
'Failed to put on the microphone, ${_errorMessageFrom(ex)}',
);
} finally {
_dismissSelfMicGoUpRetryLoading();
_isSelfMicActionInFlight = false;
}
}
xiaMai(num index) async {
if (_isSelfMicActionInFlight) {
return;
}
_isSelfMicActionInFlight = true;
try {
await SCChatRoomRepository().micGoDown(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
index,
);
final currentUserId =
AccountStorage().getCurrentUser()?.userProfile?.id ?? "";
final currentUserSeatIndex = userOnMaiInIndex(currentUserId);
if (roomWheatMap[index]?.user?.id == currentUserId) {
isMic = true;
}
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
if (currentUserSeatIndex > -1) {
_startSelfMicReleaseGuard(sourceIndex: currentUserSeatIndex);
} else {
_clearSelfMicReleaseGuard();
}
SCHeartbeatUtils.cancelAnchorTimer();
await _switchAgoraToAudience();
_clearUserFromSeats(currentUserId);
_syncSelfMicRuntimeState();
notifyListeners();
requestMicrophoneListRefresh(
notifyIfUnchanged: false,
minInterval: const Duration(milliseconds: 350),
);
} catch (ex) {
if (_isCancelledRequest(ex)) {
_refreshMicListSilently(notifyIfUnchanged: true);
return;
}
SCTts.show('Failed to leave the microphone, ${_errorMessageFrom(ex)}');
} finally {
_isSelfMicActionInFlight = false;
}
}
///踢人下麦
killXiaMai(String userId) {
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");
}
}
});
} catch (e) {
SCTts.show("kill the user fail");
debugPrint('踢人下麦失败');
}
}
///自己是否在麦上
bool isOnMai() {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return false;
}
for (final entry in roomWheatMap.entries) {
if ((micAtIndexForDisplay(entry.key)?.user?.id ?? "").trim() ==
currentUserId) {
return true;
}
}
return false;
}
///自己是否在指定的麦上
bool isOnMaiInIndex(num index) {
return (micAtIndexForDisplay(index)?.user?.id ?? "").trim() ==
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
}
///点击的用户在哪个麦上
num userOnMaiInIndex(String userId) {
final normalizedUserId = userId.trim();
if (normalizedUserId.isEmpty) {
return -1;
}
num index = -1;
roomWheatMap.forEach((k, value) {
final visibleSeat =
normalizedUserId ==
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "")
.trim()
? micAtIndexForDisplay(k)
: value;
if ((visibleSeat?.user?.id ?? "").trim() == normalizedUserId) {
index = k;
}
});
return index;
}
///座位上的用户是否开麦
bool userOnMaiInIndexOfMute(String userId) {
bool micMute = false;
roomWheatMap.forEach((k, value) {
if (value.user?.id == userId) {
value.micMute;
}
});
return micMute;
}
///是否是房主
bool isFz() {
return currenRoom?.entrants?.roles == SCRoomRolesType.HOMEOWNER.name;
}
///是否是管理
bool isGL() {
return currenRoom?.entrants?.roles == SCRoomRolesType.ADMIN.name;
}
///是否是会员
bool isHY() {
return currenRoom?.entrants?.roles == SCRoomRolesType.MEMBER.name;
}
///是否是游客
bool isTourists() {
return currenRoom?.entrants?.roles == SCRoomRolesType.TOURIST.name;
}
///麦位变动
void micChange(List<MicRes>? mics) {
if (mics == null || mics.isEmpty) {
return;
}
_applyMicSnapshot(_buildMicMap(mics));
}
///找一个空麦位 -1表示没有空位
num findWheat() {
for (var entry in roomWheatMap.entries) {
if (entry.value.user == null && !entry.value.micLock!) {
return entry.key;
}
}
return -1;
}
Future<bool> loadRoomInfo(String roomId) async {
try {
debugPrint("[Room Cover Sync] loadRoomInfo start roomId=$roomId");
final value = await SCChatRoomRepository().specific(roomId);
final currentRoomProfile = currenRoom?.roomProfile?.roomProfile;
debugPrint(
"[Room Cover Sync] loadRoomInfo fetched roomId=${value.id ?? roomId} roomCover=${value.roomCover ?? ""} roomBackground=${value.roomBackground ?? ""} roomName=${value.roomName ?? ""}",
);
currenRoom = currenRoom?.copyWith(
roomProfile: currenRoom?.roomProfile?.copyWith(
roomCounter: value.counter ?? currenRoom?.roomProfile?.roomCounter,
roomSetting: value.setting ?? currenRoom?.roomProfile?.roomSetting,
roomProfile: currentRoomProfile?.copyWith(
roomCover: _preferNonEmpty(
value.roomCover,
currentRoomProfile.roomCover,
),
roomBackground: _preferNonEmpty(
value.roomBackground,
currentRoomProfile.roomBackground,
),
roomName: _preferNonEmpty(
value.roomName,
currentRoomProfile.roomName,
),
roomDesc: _preferNonEmpty(
value.roomDesc,
currentRoomProfile.roomDesc,
),
),
),
);
debugPrint(
"[Room Cover Sync] loadRoomInfo applied roomId=$roomId roomCover=${currenRoom?.roomProfile?.roomProfile?.roomCover ?? ""} roomBackground=${currenRoom?.roomProfile?.roomProfile?.roomBackground ?? ""}",
);
///如果是游客禁止上麦,已经在麦上的游客需要下麦
bool touristMike =
currenRoom?.roomProfile?.roomSetting?.touristMike ?? false;
if (!touristMike && isTourists()) {
num index = userOnMaiInIndex(
AccountStorage().getCurrentUser()?.userProfile?.id ?? "",
);
if (index > -1) {
xiaMai(index);
}
}
notifyListeners();
return true;
} catch (e) {
await resetLocalRoomState(fallbackRtmProvider: rtmProvider);
SCTts.show("Room not exists");
return false;
}
}
///解锁麦位
Future<void> jieFeng(num index) async {
await SCChatRoomRepository().micLock(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
index,
false,
);
final mic = roomWheatMap[index];
if (mic != null) {
roomWheatMap[index] = mic.copyWith(micLock: false);
notifyListeners();
}
_refreshMicListSilently();
}
///锁麦
Future<void> fengMai(num index) async {
await SCChatRoomRepository().micLock(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
index,
true,
);
final mic = roomWheatMap[index];
if (mic != null) {
roomWheatMap[index] = mic.copyWith(micLock: true);
notifyListeners();
}
_refreshMicListSilently();
}
///静音麦克风
Future<void> jinMai(num index) async {
await SCChatRoomRepository().micMute(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
index,
true,
);
if (isOnMaiInIndex(index)) {
await _switchAgoraToBroadcaster(
publisherToken: roomWheatMap[index]?.roomToken ?? "",
muted: true,
);
}
var mic = roomWheatMap[index];
if (mic != null) {
roomWheatMap[index] = mic.copyWith(micMute: true);
notifyListeners();
}
_refreshMicListSilently();
}
///解除静音麦克风
Future<void> jieJinMai(num index) async {
await SCChatRoomRepository().micMute(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
index,
false,
);
if (isOnMaiInIndex(index)) {
if (!isMic) {
if (roomWheatMap[index]?.user != null) {
await _switchAgoraToBroadcaster(
publisherToken: roomWheatMap[index]?.roomToken ?? "",
muted: false,
);
}
}
}
var mic = roomWheatMap[index];
if (mic != null) {
roomWheatMap[index] = mic.copyWith(micMute: false);
notifyListeners();
}
_refreshMicListSilently();
}
void addOnlineUser(String groupId, SocialChatUserProfile user) {
if (groupId != currenRoom?.roomProfile?.roomProfile?.roomAccount) {
return;
}
final mergedUser = _mergeKnownVipIntoProfile(user) ?? user;
bool isExtOnlineList = false;
for (var us in onlineUsers) {
if (us.id == mergedUser.id) {
isExtOnlineList = true;
break;
}
}
if (!isExtOnlineList) {
Provider.of<RtcProvider>(
context!,
listen: false,
).onlineUsers.add(mergedUser);
_refreshManagerUsers(onlineUsers);
notifyListeners();
}
}
void removOnlineUser(String groupId, String userId) {
if (groupId != currenRoom?.roomProfile?.roomProfile?.roomAccount) {
return;
}
SocialChatUserProfile? isExtOnlineUser;
for (var us in onlineUsers) {
if (us.id == userId) {
isExtOnlineUser = us;
break;
}
}
if (isExtOnlineUser != null) {
Provider.of<RtcProvider>(
context!,
listen: false,
).onlineUsers.remove(isExtOnlineUser);
_refreshManagerUsers(onlineUsers);
notifyListeners();
}
}
void starPlayEmoji(Msg msg) {
final seatIndex = msg.number;
if (seatIndex == null || seatIndex < 0) {
return;
}
var mic = roomWheatMap[seatIndex];
if (mic != null && mic.user != null) {
_emojiPlaybackEventVersion += 1;
_seatEmojiEventVersions[seatIndex] = _emojiPlaybackEventVersion;
roomWheatMap[seatIndex] = mic.copyWith(
emojiPath: msg.msg,
type: msg.type,
number: msg.msg,
);
notifyListeners();
}
}
///更新房间背景
void updateRoomBG(SCRoomThemeListRes res) {
var roomTheme = RoomTheme(
expireTime: res.expireTime,
id: res.id,
themeStatus: res.themeStatus,
themeBack: res.themeBack,
);
currenRoom?.roomProps?.setRoomTheme(roomTheme);
notifyListeners();
}
void updateRoomSetting(RoomSetting roomSetting) {
currenRoom?.roomProfile?.setRoomSetting(roomSetting);
notifyListeners();
}
Map<num, GlobalKey<State<StatefulWidget>>> seatGlobalKeyMap = {};
void bindTargetKey(num index, GlobalKey<State<StatefulWidget>> targetKey) {
seatGlobalKeyMap[index] = targetKey;
}
GlobalKey<State<StatefulWidget>>? getSeatGlobalKeyByIndex(String userId) {
///需要有人的座位
num index = userOnMaiInIndex(userId);
if (index > -1) {
return seatGlobalKeyMap[index];
}
return null;
}
///播放音乐
void startAudioMixing(String localPath, int cycle) {
if (localPath.isEmpty) {
return;
}
engine?.startAudioMixing(
filePath: localPath,
loopback: false,
cycle: cycle,
);
}
void adjustRecordingSignalVolume(int volume) {
engine?.adjustRecordingSignalVolume(volume);
}
void setRoomMusicMixingStateListener(RoomMusicMixingStateListener? listener) {
_roomMusicMixingStateListener = listener;
}
void setRoomMusicMicRouteChangedListener(
RoomMusicMicRouteChangedListener? listener,
) {
_roomMusicMicRouteChangedListener = listener;
}
Future<void> startRoomMusicAudioMixing({
required String filePath,
required bool loopback,
int cycle = 1,
int startPos = 0,
}) async {
if (filePath.trim().isEmpty) {
return;
}
await engine?.startAudioMixing(
filePath: filePath,
loopback: loopback,
cycle: cycle,
startPos: startPos,
);
}
Future<void> pauseRoomMusicAudioMixing() async {
await engine?.pauseAudioMixing();
}
Future<void> resumeRoomMusicAudioMixing() async {
await engine?.resumeAudioMixing();
}
Future<void> stopRoomMusicAudioMixing() async {
await engine?.stopAudioMixing();
}
Future<void> setRoomMusicAudioMixingPosition(int positionMs) async {
await engine?.setAudioMixingPosition(positionMs);
}
Future<int> getRoomMusicAudioMixingPosition() async {
return await engine?.getAudioMixingCurrentPosition() ?? 0;
}
Future<int> getRoomMusicAudioMixingDuration() async {
return await engine?.getAudioMixingDuration() ?? 0;
}
Future<void> setRoomMusicAudioMixingVolume(int volume) async {
final safeVolume = volume.clamp(0, 100).toInt();
await engine?.adjustAudioMixingPlayoutVolume(safeVolume);
await engine?.adjustAudioMixingPublishVolume(safeVolume);
}
}