From 1b4d0c40c614b9a8dd5def7bd8a29d1a0bf9d533 Mon Sep 17 00:00:00 2001 From: roxy Date: Wed, 29 Apr 2026 09:56:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BA=A6=E4=BD=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner.xcodeproj/project.pbxproj | 12 +- .../room_game/utils/room_game_viewport.dart | 184 +++--- .../room_game/views/baishun_game_page.dart | 44 +- .../room_game/views/leader_game_page.dart | 45 +- lib/services/audio/rtc_manager.dart | 527 +++++++++++++++--- lib/services/audio/rtm_manager.dart | 29 +- .../widgets/room/room_bottom_widget.dart | 30 +- pubspec.yaml | 2 +- 8 files changed, 624 insertions(+), 249 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 77837a9..9195c54 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -502,7 +502,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = S9X2AJ2US9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -693,7 +693,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -722,7 +722,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -731,7 +731,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.2; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.org.yumiparty; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/lib/modules/room_game/utils/room_game_viewport.dart b/lib/modules/room_game/utils/room_game_viewport.dart index 7f9f1d9..acb563a 100644 --- a/lib/modules/room_game/utils/room_game_viewport.dart +++ b/lib/modules/room_game/utils/room_game_viewport.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; const double roomGameDesignWidth = 750; @@ -64,79 +62,115 @@ String resolveRoomGameScreenMode( return fullScreen ? 'full' : 'half'; } -String buildRoomGameNativeFitScript({ - required double visibleHeight, - required double expectedHeight, -}) { - final options = jsonEncode({ - 'visibleHeight': _roundMetric(visibleHeight), - 'expectedHeight': _roundMetric(expectedHeight), - }); - return ''' - (function() { - const options = $options; - const fitKey = '__roomGameNativeViewportFit'; - - function applyFit() { - try { - const visibleHeight = Number(options.visibleHeight) || window.innerHeight || 0; - const expectedHeight = Number(options.expectedHeight) || 0; - if (!document.body || !visibleHeight || !expectedHeight) { - return; - } - - const rawScale = visibleHeight / expectedHeight; - const scale = Math.min(1, rawScale); - window[fitKey] = { - scale: scale, - visibleHeight: visibleHeight, - expectedHeight: expectedHeight, - compact: scale < 0.999 - }; - - if (scale >= 0.999) { - document.documentElement.style.overflow = ''; - document.body.style.overflow = ''; - document.body.style.transform = ''; - document.body.style.transformOrigin = ''; - document.body.style.width = ''; - document.body.style.minHeight = ''; - document.documentElement.removeAttribute('data-room-game-native-fit'); - return; - } - - document.documentElement.setAttribute('data-room-game-native-fit', 'true'); - document.documentElement.style.overflow = 'hidden'; - document.body.style.overflow = 'hidden'; - document.body.style.transform = 'scale(' + scale + ')'; - document.body.style.transformOrigin = 'top left'; - document.body.style.width = (100 / scale) + '%'; - document.body.style.minHeight = expectedHeight + 'px'; - } catch (_) {} - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', applyFit, { once: true }); - } else { - applyFit(); - } - [50, 250, 800, 1600, 3000].forEach(function(delay) { - window.setTimeout(applyFit, delay); - }); - if (!window.__roomGameNativeFitResizeReady) { - window.__roomGameNativeFitResizeReady = true; - window.addEventListener('resize', applyFit, true); - window.addEventListener('orientationchange', function() { - window.setTimeout(applyFit, 120); - }, true); - } - })(); - '''; -} - -double _roundMetric(double value) { - if (!value.isFinite) { +double resolveRoomGameCompatibilityBottomHeight(BuildContext context) { + final mediaQuery = MediaQuery.maybeOf(context); + if (mediaQuery == null) { return 0; } - return double.parse(value.toStringAsFixed(2)); + final isClassicScreen = mediaQuery.padding.bottom <= 0.5; + if (!isClassicScreen) { + return 0; + } + + return (mediaQuery.size.width * 0.19).clamp(56.0, 88.0).toDouble(); +} + +class RoomGameCompatibilityBottomFrame extends StatelessWidget { + const RoomGameCompatibilityBottomFrame({super.key, required this.height}); + + final double height; + + @override + Widget build(BuildContext context) { + if (height <= 0) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: height, + width: double.infinity, + child: CustomPaint(painter: _RoomGameBottomFramePainter()), + ); + } +} + +class _RoomGameBottomFramePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final radius = Radius.circular(size.height * 0.18); + final frameRect = RRect.fromRectAndCorners( + Offset.zero & size, + topLeft: radius, + topRight: radius, + ); + + final backgroundPaint = + Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF0E392E), Color(0xFF082A23)], + ).createShader(Offset.zero & size); + canvas.drawRRect(frameRect, backgroundPaint); + + canvas.save(); + canvas.clipRRect(frameRect); + final patternPaint = + Paint() + ..color = const Color(0xFF2C5B4A).withValues(alpha: 0.20) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final motifWidth = size.width / 8; + final motifHeight = size.height / 2.4; + for (double x = -motifWidth; x < size.width + motifWidth; x += motifWidth) { + for ( + double y = -motifHeight * 0.2; + y < size.height + motifHeight; + y += motifHeight * 0.72 + ) { + final rect = Rect.fromCenter( + center: Offset(x + motifWidth * 0.52, y + motifHeight * 0.55), + width: motifWidth * 0.9, + height: motifHeight, + ); + canvas.drawArc(rect, -0.35, 4.5, false, patternPaint); + canvas.drawArc( + rect.translate(motifWidth * 0.34, 0), + 2.75, + 3.6, + false, + patternPaint, + ); + } + } + canvas.restore(); + + final borderPaint = + Paint() + ..color = const Color(0xFFE9D58A) + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + final innerBorderPaint = + Paint() + ..color = const Color(0xFF8F7136).withValues(alpha: 0.75) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + canvas.drawRRect(frameRect.deflate(1.5), borderPaint); + canvas.drawRRect(frameRect.deflate(5), innerBorderPaint); + + final highlightPaint = + Paint() + ..shader = const LinearGradient( + colors: [ + Color(0x00F6E8A7), + Color(0x99F6E8A7), + Color(0x00F6E8A7), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, 3)); + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, 3), highlightPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } diff --git a/lib/modules/room_game/views/baishun_game_page.dart b/lib/modules/room_game/views/baishun_game_page.dart index c89c5bc..3639d4c 100644 --- a/lib/modules/room_game/views/baishun_game_page.dart +++ b/lib/modules/room_game/views/baishun_game_page.dart @@ -106,7 +106,6 @@ class _BaishunGamePageState extends State { _didFinishPageLoad = true; _log('page_finished url=${_clip(url, 240)}'); await _injectBridge(reason: 'page_finished'); - await _applyNativeViewportFit(reason: 'page_finished'); }, onWebResourceError: (WebResourceError error) { _log( @@ -274,7 +273,6 @@ class _BaishunGamePageState extends State { jsCallbackPath: jsCallbackPath, ), ); - await _applyNativeViewportFit(reason: 'send_config'); } catch (error) { _log('send_config_error error=${_clip(error.toString(), 300)}'); } @@ -332,7 +330,6 @@ class _BaishunGamePageState extends State { _isLoading = false; _errorMessage = null; }); - await _applyNativeViewportFit(reason: 'game_loaded'); break; case BaishunBridgeActions.gameRecharge: _log('game_recharge open_wallet'); @@ -636,35 +633,12 @@ class _BaishunGamePageState extends State { return screenSize.width / ratio; } - Future _applyNativeViewportFit({String reason = 'manual'}) async { - final topCrop = 28.w; - final visibleHeight = _calculateWebViewHeight(context) + topCrop; - final expectedHeight = _calculatePreferredWebViewHeight(context); - if (expectedHeight <= visibleHeight + 1) { - return; - } - _log( - 'native_fit reason=$reason visible=${visibleHeight.toStringAsFixed(1)} ' - 'expected=${expectedHeight.toStringAsFixed(1)}', - ); - try { - await _controller.runJavaScript( - buildRoomGameNativeFitScript( - visibleHeight: visibleHeight, - expectedHeight: expectedHeight, - ), - ); - } catch (error) { - _log( - 'native_fit_error reason=$reason error=${_clip(error.toString(), 300)}', - ); - } - } - @override Widget build(BuildContext context) { final topCrop = 28.w; final webViewHeight = _calculateWebViewHeight(context); + final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context); + final dialogHeight = webViewHeight + bottomFrameHeight; return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { @@ -684,7 +658,7 @@ class _BaishunGamePageState extends State { ), child: Container( width: ScreenUtil().screenWidth, - height: webViewHeight, + height: dialogHeight, color: const Color(0xFF081915), child: Stack( children: [ @@ -692,12 +666,22 @@ class _BaishunGamePageState extends State { top: -topCrop, left: 0, right: 0, - bottom: 0, + bottom: bottomFrameHeight, child: WebViewWidget( controller: _controller, gestureRecognizers: _webGestureRecognizers, ), ), + if (bottomFrameHeight > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: bottomFrameHeight, + child: RoomGameCompatibilityBottomFrame( + height: bottomFrameHeight, + ), + ), if (_errorMessage != null) _buildErrorState(), if (_isLoading && _errorMessage == null) const IgnorePointer( diff --git a/lib/modules/room_game/views/leader_game_page.dart b/lib/modules/room_game/views/leader_game_page.dart index 0f22bba..25ca8a0 100644 --- a/lib/modules/room_game/views/leader_game_page.dart +++ b/lib/modules/room_game/views/leader_game_page.dart @@ -219,7 +219,6 @@ class _LeaderGamePageState extends State { await _controller.runJavaScript( LeaderJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()), ); - await _applyNativeViewportFit(reason: reason); } catch (error) { _log( 'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}', @@ -387,35 +386,6 @@ class _LeaderGamePageState extends State { return fallback; } - Future _applyNativeViewportFit({String reason = 'manual'}) async { - final screenMode = _resolveScreenMode(); - if (screenMode == 'full') { - return; - } - final headerHeight = 44.w; - final visibleHeight = _calculateBodyHeight(context, headerHeight); - final expectedHeight = _calculatePreferredBodyHeight(context); - if (expectedHeight <= visibleHeight + 1) { - return; - } - _log( - 'native_fit reason=$reason visible=${visibleHeight.toStringAsFixed(1)} ' - 'expected=${expectedHeight.toStringAsFixed(1)}', - ); - try { - await _controller.runJavaScript( - buildRoomGameNativeFitScript( - visibleHeight: visibleHeight, - expectedHeight: expectedHeight, - ), - ); - } catch (error) { - _log( - 'native_fit_error reason=$reason error=${_clip(error.toString(), 300)}', - ); - } - } - void _log(String message) { debugPrint('$_logPrefix $message'); } @@ -576,6 +546,7 @@ class _LeaderGamePageState extends State { Widget build(BuildContext context) { final headerHeight = 44.w; final bodyHeight = _calculateBodyHeight(context, headerHeight); + final bottomFrameHeight = resolveRoomGameCompatibilityBottomHeight(context); return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { @@ -595,7 +566,7 @@ class _LeaderGamePageState extends State { ), child: Container( width: ScreenUtil().screenWidth, - height: bodyHeight + headerHeight, + height: bodyHeight + headerHeight + bottomFrameHeight, color: const Color(0xFF081915), child: Stack( children: [ @@ -610,12 +581,22 @@ class _LeaderGamePageState extends State { left: 0, right: 0, top: headerHeight, - bottom: 0, + bottom: bottomFrameHeight, child: WebViewWidget( controller: _controller, gestureRecognizers: _webGestureRecognizers, ), ), + if (bottomFrameHeight > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: bottomFrameHeight, + child: RoomGameCompatibilityBottomFrame( + height: bottomFrameHeight, + ), + ), if (_errorMessage != null) _buildErrorState(), if (_isLoading && _errorMessage == null) const IgnorePointer( diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 4d3032d..3252ae0 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -57,16 +57,25 @@ class RealTimeCommunicationManager extends ChangeNotifier { static const Duration _giftTriggeredMicRefreshMinInterval = Duration( milliseconds: 900, ); + static const Duration _agoraJoinTimeout = Duration(seconds: 8); + static const Duration _agoraDisconnectedGracePeriod = Duration(seconds: 12); bool needUpDataUserInfo = false; bool _roomVisualEffectsEnabled = false; bool _isExitingCurrentVoiceRoomSession = false; + bool _isHandlingAgoraRoomFailure = false; + bool _agoraJoined = false; + bool _agoraRoleIsBroadcaster = false; RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle; RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none; int _roomStartupFailureToken = 0; bool _isHandlingRoomStartupFailure = false; Timer? _micListPollingTimer; Timer? _onlineUsersPollingTimer; + Timer? _joinAgoraTimeoutTimer; + Timer? _agoraDisconnectedCleanupTimer; + Completer? _joinAgoraCompleter; + String? _joiningChannelId; bool _isRefreshingMicList = false; bool _isRefreshingOnlineUsers = false; bool _disableMicListRefreshForCurrentSession = false; @@ -580,6 +589,96 @@ class RealTimeCommunicationManager extends ChangeNotifier { 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(); + _cancelAgoraDisconnectedCleanup(); + _agoraJoined = false; + _agoraRoleIsBroadcaster = false; + 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) { @@ -597,6 +696,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (currentUserMic == null) { _clearSelfMicSwitchGuard(clearPreferredIndex: true); + _agoraRoleIsBroadcaster = false; _syncRoomHeartbeatState( roomId: roomId, isOnMic: false, @@ -610,23 +710,16 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _preferredSelfMicIndex = currentUserMic.micIndex; + final canReportMicActive = _canReportMicActive; _syncRoomHeartbeatState( roomId: roomId, - isOnMic: true, - shouldRunAnchorHeartbeat: true, + isOnMic: canReportMicActive, + shouldRunAnchorHeartbeat: canReportMicActive, ); - if ((currentUserMic.micMute ?? false) || isMic) { - _applyLocalAudioRuntimeState( - clientRole: ClientRoleType.clientRoleAudience, - muted: true, - ); - return; - } - _applyLocalAudioRuntimeState( clientRole: ClientRoleType.clientRoleBroadcaster, - muted: false, + muted: (currentUserMic.micMute ?? false) || isMic, ); } @@ -641,6 +734,19 @@ class RealTimeCommunicationManager extends ChangeNotifier { return; } + if (!_agoraJoined) { + if (_lastScheduledVoiceLiveRoomId != null || + _lastScheduledAnchorRoomId != null) { + SCHeartbeatUtils.scheduleHeartbeat( + SCHeartbeatStatus.ONLINE.name, + false, + ); + SCHeartbeatUtils.cancelAnchorTimer(); + } + _resetHeartbeatTracking(); + return; + } + final voiceLiveChanged = _lastScheduledVoiceLiveRoomId != normalizedRoomId || _lastScheduledVoiceLiveOnMic != isOnMic; @@ -692,6 +798,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { required ClientRoleType clientRole, required bool muted, }) { + if (clientRole != ClientRoleType.clientRoleBroadcaster) { + _agoraRoleIsBroadcaster = false; + } if (engine == null) { _lastAppliedClientRole = null; _lastAppliedLocalAudioMuted = null; @@ -818,7 +927,83 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } + Future _cleanupAgoraRoomState({bool clearMicSeats = false}) async { + _resetAgoraTracking(); + try { + await engine?.leaveChannel(); + } catch (error) { + debugPrint('[Agora] leave channel during cleanup failed: $error'); + } + if (clearMicSeats) { + roomWheatMap.clear(); + notifyListeners(); + } + _resyncHeartbeatFromAgoraState(); + } + + Future _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 _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 joinAgoraVoiceChannel({bool throwOnError = false}) 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; + } + + _cancelAgoraJoinWaiter(); + _cancelAgoraDisconnectedCleanup(); + _joiningChannelId = channelId; + _agoraJoined = false; + _agoraRoleIsBroadcaster = false; + final joinCompleter = Completer(); + _joinAgoraCompleter = joinCompleter; + _joinAgoraTimeoutTimer = Timer(_agoraJoinTimeout, () { + if (!joinCompleter.isCompleted) { + joinCompleter.completeError( + TimeoutException('Agora join timeout', _agoraJoinTimeout), + ); + } + }); + try { final rtcEngine = await _initAgoraRtcEngine(); rtcEngine.setAudioProfile( @@ -832,15 +1017,15 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); await rtcEngine.disableVideo(); var rtcToken = await SCAccountRepository().getRtcToken( - currenRoom?.roomProfile?.roomProfile?.id ?? "", + channelId, AccountStorage().getCurrentUser()?.userProfile?.id ?? "", isPublisher: false, ); await rtcEngine.joinChannel( token: rtcToken.rtcToken ?? "", - channelId: currenRoom?.roomProfile?.roomProfile?.id ?? "", + channelId: channelId, uid: _resolveAgoraUidForCurrentUser(), - options: ChannelMediaOptions( + options: const ChannelMediaOptions( // 自动订阅所有视频流 autoSubscribeVideo: false, // 自动订阅所有音频流 @@ -854,15 +1039,122 @@ class RealTimeCommunicationManager extends ChangeNotifier { channelProfile: ChannelProfileType.channelProfileLiveBroadcasting, ), ); + await joinCompleter.future; _syncSelfMicRuntimeState(); rtcEngine.muteAllRemoteAudioStreams(roomIsMute); } catch (e) { + await _handleAgoraJoinFailed(e, exitRoom: !throwOnError); if (throwOnError) { rethrow; } - SCTts.show("Join room fail"); - exitCurrentVoiceRoomSession(false); print('加入失败:${e.runtimeType},${e.toString()}'); + } finally { + if (identical(_joinAgoraCompleter, joinCompleter)) { + _cancelAgoraJoinWaiter(); + } + } + } + + Future _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 _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 setSelfMicMutedFromBottom(bool muted) async { + isMic = muted; + 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 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), + ); } } @@ -878,6 +1170,119 @@ class RealTimeCommunicationManager extends ChangeNotifier { _rtcEngineEventHandler = RtcEngineEventHandler( onError: (ErrorCodeType err, String msg) { print('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) { + print('rtc 自己加入 ${connection.channelId} ${connection.localUid}'); + final joinedChannelId = connection.channelId ?? ""; + if (!_isCurrentAgoraChannel(joinedChannelId)) { + return; + } + _agoraJoined = true; + _agoraRoleIsBroadcaster = false; + _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; + _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, @@ -895,9 +1300,6 @@ class RealTimeCommunicationManager extends ChangeNotifier { break; } }, - onJoinChannelSuccess: (RtcConnection connection, int elapsed) { - print('rtc 自己加入 ${connection.channelId} ${connection.localUid}'); - }, onUserJoined: (connection, remoteUid, elapsed) { print('rtc用户 $remoteUid 加入了频道'); }, @@ -912,7 +1314,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { var rtcToken = await SCAccountRepository().getRtcToken( currenRoom?.roomProfile?.roomProfile?.id ?? "", AccountStorage().getCurrentUser()?.userProfile?.id ?? "", - isPublisher: isOnMai(), + isPublisher: _agoraRoleIsBroadcaster, ); engine?.renewToken(rtcToken.rtcToken ?? ""); }, @@ -940,6 +1342,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } catch (e) { print('rtc销毁前离开频道出错: $e'); } + _resetAgoraTracking(); try { await rtcEngine.release(); @@ -1207,8 +1610,6 @@ class RealTimeCommunicationManager extends ChangeNotifier { ), ); - await _bootstrapRoomHeartbeatState(); - ///获取麦位 retrieveMicrophoneList(); fetchOnlineUsersList(); @@ -1222,6 +1623,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { failureType = RoomStartupFailureType.rtc; await joinAgoraVoiceChannel(throwOnError: true); + await _bootstrapRoomHeartbeatState(); _setRoomStartupReady(); Msg joinMsg = Msg( @@ -1421,7 +1823,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { SCHeartbeatUtils.scheduleHeartbeat(SCHeartbeatStatus.ONLINE.name, false); SCHeartbeatUtils.cancelAnchorTimer(); rtmProvider?.cleanRoomData(); - await engine?.leaveChannel(); + await _cleanupAgoraRoomState(); await Future.delayed(Duration(milliseconds: 100)); await rtmProvider?.quitGroup( currenRoom!.roomProfile?.roomProfile?.roomAccount ?? "", @@ -1454,7 +1856,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (groupId.isNotEmpty) { await rtmProvider?.quitGroup(groupId); } - await engine?.leaveChannel(); + await _cleanupAgoraRoomState(); await Future.delayed(const Duration(milliseconds: 100)); } catch (e) { print('rtc清理本地房间状态出错: $e'); @@ -1487,7 +1889,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (groupId.isNotEmpty) { await rtmProvider?.quitGroup(groupId); } - await engine?.leaveChannel(); + await _cleanupAgoraRoomState(clearMicSeats: true); await Future.delayed(const Duration(milliseconds: 100)); if (roomId.isNotEmpty) { await SCAccountRepository().quitRoom(roomId); @@ -1508,13 +1910,14 @@ class RealTimeCommunicationManager extends ChangeNotifier { void _clearData() { _stopRoomStatePolling(); _resetHeartbeatTracking(); - _resetLocalAudioRuntimeTracking(); + _resetAgoraTracking(); _disableMicListRefreshForCurrentSession = false; _disableOnlineUsersRefreshForCurrentSession = false; _isSelfMicActionInFlight = false; _roomStartupStatus = RoomStartupStatus.idle; _roomStartupFailureType = RoomStartupFailureType.none; _isHandlingRoomStartupFailure = false; + _isHandlingAgoraRoomFailure = false; _pendingMicListRefresh = false; _pendingMicListRefreshNotifyIfUnchanged = false; _seatEmojiEventVersions.clear(); @@ -1719,22 +2122,32 @@ class RealTimeCommunicationManager extends ChangeNotifier { inviterId: inviterId, ); - /// 设置成主播角色 - engine?.renewToken(micGoUpRes.roomToken ?? ""); - if (!micGoUpRes.micMute!) { - if (!isMic) { - engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster); - adjustRecordingSignalVolume(100); - await engine?.muteLocalAudioStream(false); - } - } else { - engine?.setClientRole(role: ClientRoleType.clientRoleAudience); - await engine?.muteLocalAudioStream(true); - } - SCHeartbeatUtils.scheduleAnchorHeartbeat( - currenRoom?.roomProfile?.roomProfile?.id ?? "", - ); 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); + SCTts.show('Failed to put on the microphone, Agora switch failed'); + return; + } + final previousSelfSeatIndex = myUser != null ? userOnMaiInIndex(myUser.id ?? "") : -1; final isSeatSwitching = @@ -1806,7 +2219,6 @@ class RealTimeCommunicationManager extends ChangeNotifier { final currentUserSeatIndex = userOnMaiInIndex(currentUserId); if (roomWheatMap[index]?.user?.id == currentUserId) { isMic = true; - engine?.muteLocalAudioStream(true); } _clearSelfMicSwitchGuard(clearPreferredIndex: true); if (currentUserSeatIndex > -1) { @@ -1816,9 +2228,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } SCHeartbeatUtils.cancelAnchorTimer(); - /// 设置成主持人角色 - engine?.renewToken(""); - engine?.setClientRole(role: ClientRoleType.clientRoleAudience); + await _switchAgoraToAudience(); _clearUserFromSeats(currentUserId); _syncSelfMicRuntimeState(); notifyListeners(); @@ -2054,14 +2464,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { true, ); if (isOnMaiInIndex(index)) { - Provider.of( - context!, - listen: false, - ).engine?.setClientRole(role: ClientRoleType.clientRoleAudience); - Provider.of( - context!, - listen: false, - ).engine?.muteLocalAudioStream(true); + await _switchAgoraToBroadcaster( + publisherToken: roomWheatMap[index]?.roomToken ?? "", + muted: true, + ); } var mic = roomWheatMap[index]; if (mic != null) { @@ -2079,17 +2485,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { false, ); if (isOnMaiInIndex(index)) { - if (!Provider.of(context!, listen: false).isMic) { + if (!isMic) { if (roomWheatMap[index]?.user != null) { - adjustRecordingSignalVolume(100); - Provider.of( - context!, - listen: false, - ).engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster); - Provider.of( - context!, - listen: false, - ).engine?.muteLocalAudioStream(false); + await _switchAgoraToBroadcaster( + publisherToken: roomWheatMap[index]?.roomToken ?? "", + muted: false, + ); } } } diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 4215263..49a5762 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; -import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -304,16 +303,16 @@ class RealTimeMessagingManager extends ChangeNotifier { if (memberList.isNotEmpty) { if (memberList.first.userID == AccountStorage().getCurrentUser()?.userProfile?.id) { - Provider.of( + final rtcProvider = Provider.of( context, listen: false, - ).engine?.setClientRole(role: ClientRoleType.clientRoleAudience); + ); + unawaited( + rtcProvider.handleSelfMicRemovedByRemote(refreshMicList: false), + ); ///退出房间 - Provider.of( - context, - listen: false, - ).exitCurrentVoiceRoomSession(false).whenComplete(() { + rtcProvider.exitCurrentVoiceRoomSession(false).whenComplete(() { SCRoomUtils.closeAllDialogs(); SmartDialog.show( tag: "showConfirmDialog", @@ -1395,16 +1394,16 @@ class RealTimeMessagingManager extends ChangeNotifier { } if (msg.type == SCRoomMsgType.killXiaMai) { ///踢下麦 - if (msg.msg == AccountStorage().getCurrentUser()?.userProfile?.id) { - Provider.of( - context!, - listen: false, - ).engine?.setClientRole(role: ClientRoleType.clientRoleAudience); - } - Provider.of( + final rtcProvider = Provider.of( context!, listen: false, - ).retrieveMicrophoneList(notifyIfUnchanged: false); + ); + if (msg.msg == AccountStorage().getCurrentUser()?.userProfile?.id) { + unawaited( + rtcProvider.handleSelfMicRemovedByRemote(refreshMicList: false), + ); + } + rtcProvider.retrieveMicrophoneList(notifyIfUnchanged: false); return; } diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index 2135dfa..f83c18d 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -1,4 +1,5 @@ -import 'package:agora_rtc_engine/agora_rtc_engine.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -13,7 +14,6 @@ import 'package:yumi/ui_kit/widgets/room/room_menu_dialog.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_input.dart'; import 'package:yumi/ui_kit/components/text/sc_text.dart'; import 'package:yumi/shared/tools/sc_room_utils.dart'; -import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; import '../../../app/routes/sc_fluro_navigator.dart'; @@ -340,32 +340,8 @@ class _RoomBottomWidgetState extends State { final provider = context.read(); setState(() { provider.isMic = !provider.isMic; - - provider.roomWheatMap.forEach((k, v) { - final seat = provider.micAtIndexForDisplay(k); - if ((seat?.user?.id ?? "").trim() == - (AccountStorage().getCurrentUser()?.userProfile?.id ?? "") - .trim() && - !(seat?.micMute ?? true)) { - if (!provider.isMic) { - provider.engine?.adjustRecordingSignalVolume(100); - provider.engine?.setClientRole( - role: ClientRoleType.clientRoleBroadcaster, - ); - provider.engine?.muteLocalAudioStream(false); - } else { - if (provider.isMusicPlaying) { - provider.engine?.adjustRecordingSignalVolume(0); - } else { - provider.engine?.setClientRole( - role: ClientRoleType.clientRoleAudience, - ); - provider.engine?.muteLocalAudioStream(provider.isMic); - } - } - } - }); }); + unawaited(provider.setSelfMicMutedFromBottom(provider.isMic)); }, child: RoomBottomCircleAction( child: Image.asset( diff --git a/pubspec.yaml b/pubspec.yaml index 5cbedaa..17068a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.2+6 +version: 1.2.5+7 environment: