diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bef7ba5..d2634e7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,9 +6,13 @@ - + + + + + @@ -130,6 +134,12 @@ android:name="io.agora.rtc2.extensions.MediaProjectionMgr$LocalScreenSharingService" tools:node="remove" /> + + diff --git a/android/app/src/main/kotlin/com/org/yumiparty/MainActivity.kt b/android/app/src/main/kotlin/com/org/yumiparty/MainActivity.kt index 15a39dc..d935d00 100644 --- a/android/app/src/main/kotlin/com/org/yumiparty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/org/yumiparty/MainActivity.kt @@ -30,6 +30,33 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + VOICE_ROOM_FOREGROUND_CHANNEL + ).setMethodCallHandler { call, result -> + when (call.method) { + "start" -> { + try { + VoiceRoomForegroundService.start( + applicationContext, + call.argument("roomName") + ) + result.success(null) + } catch (e: Exception) { + result.error("VOICE_ROOM_FOREGROUND_START_FAILED", e.message, null) + } + } + "stop" -> { + try { + VoiceRoomForegroundService.stop(applicationContext) + result.success(null) + } catch (e: Exception) { + result.error("VOICE_ROOM_FOREGROUND_STOP_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } } override fun onDestroy() { @@ -90,5 +117,7 @@ class MainActivity : FlutterActivity() { companion object { private const val INSTALL_REFERRER_CHANNEL = "com.org.yumiparty/install_referrer" private const val MOBILE_CONTEXT_CHANNEL = "com.org.yumiparty/mobile_context" + private const val VOICE_ROOM_FOREGROUND_CHANNEL = + "com.org.yumiparty/voice_room_foreground_service" } } diff --git a/android/app/src/main/kotlin/com/org/yumiparty/VoiceRoomForegroundService.kt b/android/app/src/main/kotlin/com/org/yumiparty/VoiceRoomForegroundService.kt new file mode 100644 index 0000000..484ecad --- /dev/null +++ b/android/app/src/main/kotlin/com/org/yumiparty/VoiceRoomForegroundService.kt @@ -0,0 +1,131 @@ +package com.org.yumiparty + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder + +class VoiceRoomForegroundService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + ensureNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val roomName = intent?.getStringExtra(EXTRA_ROOM_NAME) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: DEFAULT_ROOM_NAME + startAsForeground(roomName) + return START_NOT_STICKY + } + + private fun startAsForeground(roomName: String) { + val notification = buildNotification(roomName) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } + startForeground( + NOTIFICATION_ID, + notification, + serviceType + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + @Suppress("DEPRECATION") + private fun buildNotification(roomName: String): Notification { + val launchIntent = ( + packageManager.getLaunchIntentForPackage(packageName) + ?: Intent(this, MainActivity::class.java) + ).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or immutableFlag() + ) + + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + Notification.Builder(this) + } + + return builder + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Yumi voice room") + .setContentText(roomName) + .setOngoing(true) + .setShowWhen(false) + .setOnlyAlertOnce(true) + .setPriority(Notification.PRIORITY_LOW) + .setCategory(Notification.CATEGORY_CALL) + .setContentIntent(pendingIntent) + .build() + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) { + return + } + val channel = NotificationChannel( + CHANNEL_ID, + "Voice room", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps voice rooms active while Yumi is in the background." + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + private fun immutableFlag(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + + companion object { + private const val CHANNEL_ID = "yumi_voice_room" + private const val NOTIFICATION_ID = 24031 + private const val EXTRA_ROOM_NAME = "room_name" + private const val DEFAULT_ROOM_NAME = "Voice room is running" + + fun start(context: Context, roomName: String?) { + val intent = Intent(context, VoiceRoomForegroundService::class.java) + .putExtra(EXTRA_ROOM_NAME, roomName) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, VoiceRoomForegroundService::class.java)) + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 365fcc7..fcd3e81 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 = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = S9X2AJ2US9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -693,7 +693,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -722,7 +722,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = F33K8VUZ62; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 32b0b0e..09f85c2 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -15,6 +15,7 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository 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/audio/voice_room_foreground_service.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'; @@ -302,6 +303,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { 'roomStartupSeatInteractionLoading'; static const String _selfMicGoUpRetryLoadingTag = 'selfMicGoUpRetryLoading'; static const int _selfMicGoUpFailureLimit = 3; + static const List _roomRedPacketEntryRefreshDelays = [ + Duration.zero, + Duration(seconds: 2), + Duration(seconds: 6), + ]; bool needUpDataUserInfo = false; bool _roomVisualEffectsEnabled = false; @@ -443,6 +449,21 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool get shouldShowRoomVisualEffects => currenRoom != null && _roomVisualEffectsEnabled; + void _startVoiceRoomForegroundService() { + final roomProfile = currenRoom?.roomProfile?.roomProfile; + final roomName = (roomProfile?.roomName ?? "").trim(); + final roomAccount = (roomProfile?.roomAccount ?? "").trim(); + unawaited( + SCVoiceRoomForegroundService.start( + roomName: roomName.isNotEmpty ? roomName : roomAccount, + ), + ); + } + + void _stopVoiceRoomForegroundService() { + unawaited(SCVoiceRoomForegroundService.stop()); + } + bool get _shouldDeferSeatInteractionForRoomStartup => _roomStartupStatus == RoomStartupStatus.loading; @@ -1943,6 +1964,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { Future releaseRtcEngineForAppTermination() async { final rtcEngine = engine; if (rtcEngine == null) { + await SCVoiceRoomForegroundService.stop(); return; } @@ -1968,6 +1990,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } catch (e) { debugPrint('rtc释放引擎出错: $e'); } + await SCVoiceRoomForegroundService.stop(); } void initializeAudioVolumeIndicationCallback( @@ -2114,6 +2137,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (!context.mounted) { return; } + _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); return; } @@ -2137,6 +2161,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { _startRoomStatePolling(); _setRoomStartupReady(); setRoomVisualEffectsEnabled(true); + _startRoomRedPacketPresenceHeartbeat(roomId); + _refreshRoomRedPacketListAfterEntry(roomId: roomId); + _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); } else { if (currenRoom != null) { @@ -2157,6 +2184,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { previewData: previewData, previewSeatCount: previewSeatCount, ); + _startVoiceRoomForegroundService(); VoiceRoomRoute.openVoiceRoom(context); notifyListeners(); final entryRequestSerial = ++_roomEntryRequestSerial; @@ -2192,6 +2220,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom); _syncCurrentUserVipHintFromRoom(currenRoom); + _startVoiceRoomForegroundService(); unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial)); _currentRoomIsEntryPreview = false; _previewRoomSeatCount = null; @@ -2207,6 +2236,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { return; } debugPrint('[RoomStartup] entry room failed: $e\n$stackTrace'); + _stopVoiceRoomForegroundService(); _setRoomStartupFailed(RoomStartupFailureType.entry); } } @@ -2674,6 +2704,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { debugPrint( '[RoomStartup] join IM group failed code=${joinResult?.code} desc=${joinResult?.desc}', ); + _stopVoiceRoomForegroundService(); _setRoomStartupFailed(RoomStartupFailureType.im); return; } @@ -2693,6 +2724,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { rtmProvider?.loadRoomHistoryMessages(groupId, count: 20) ?? Future.value(), ); + _refreshRoomRedPacketListAfterEntry(roomId: roomId); await Future.wait([microphoneListFuture, onlineUsersFuture]); if (!_isActiveRoomStartup(entryRequestSerial, roomId)) { @@ -2748,6 +2780,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { return; } debugPrint('[RoomStartup] bootstrap failed: $error\n$stackTrace'); + _stopVoiceRoomForegroundService(); _setRoomStartupFailed(failureType); } } @@ -2988,11 +3021,17 @@ class RealTimeCommunicationManager extends ChangeNotifier { Future> loadRoomRedPacketList( int current, ) async { + final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; + if (roomId.isEmpty) { + return const []; + } var result = await SCChatRoomRepository().roomRedPacketList( - currenRoom?.roomProfile?.roomProfile?.id ?? "", + roomId, current, ); - if (current == 1) { + final currentRoomId = + currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; + if (current == 1 && currentRoomId == roomId) { redPacketList = result; notifyListeners(); } @@ -3055,19 +3094,18 @@ class RealTimeCommunicationManager extends ChangeNotifier { ); } - void _refreshRoomRedPacketListAfterEntry() { - final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; - if (roomId.isEmpty) { + void _refreshRoomRedPacketListAfterEntry({String? roomId}) { + final normalizedRoomId = + roomId?.trim() ?? + currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? + ""; + if (normalizedRoomId.isEmpty) { return; } - for (final delay in const [ - Duration.zero, - Duration(seconds: 2), - Duration(seconds: 6), - ]) { + for (final delay in _roomRedPacketEntryRefreshDelays) { unawaited( Future.delayed(delay).then((_) { - return _refreshRoomRedPacketListIfCurrent(roomId); + return _refreshRoomRedPacketListIfCurrent(normalizedRoomId); }), ); } @@ -3381,6 +3419,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///清空列表数据 void _clearData() { _roomEntryRequestSerial += 1; + _stopVoiceRoomForegroundService(); _roomEntryEffectTimer?.cancel(); _roomEntryEffectTimer = null; _stopRoomRedPacketPresenceHeartbeat(); diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index 8cf06f7..ee17017 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -571,6 +571,7 @@ class RealTimeMessagingManager extends ChangeNotifier { logined = true; print('tim 登录成功'); await initConversation(); + unawaited(syncRoomRedPacketBroadcastGroup()); } else { // 登录失败逻辑 //print('timm 需要重新登录2'); @@ -591,16 +592,26 @@ class RealTimeMessagingManager extends ChangeNotifier { _onNewMessage(V2TimMessage message) { if (message.groupID != null) { + final groupId = message.groupID!; + ///全服通知 / 语区广播群 - if (message.groupID == SCGlobalConfig.bigBroadcastGroup || - message.groupID == roomRedPacketBroadcastGroupId) { - _newBroadCastMsgRecv(message.groupID!, message); + if (groupId == SCGlobalConfig.bigBroadcastGroup || + groupId == roomRedPacketBroadcastGroupId) { + _newBroadCastMsgRecv(groupId, message); + return; + } + if (_shouldRouteUntrackedRoomRedPacketBroadcast(groupId, message)) { + debugPrint( + '[RoomRedPacket][IM] route untracked group as region broadcast ' + 'group=$groupId cachedRegionGroup=$roomRedPacketBroadcastGroupId', + ); + _newBroadCastMsgRecv(groupId, message, forceRegionBroadcast: true); return; } ///群消息 for (var element in onNewMessageListenerGroupMap.values) { - element?.call(message.groupID!, message); + element?.call(groupId, message); } } else { ///单聊消息 @@ -1780,6 +1791,38 @@ class RealTimeMessagingManager extends ChangeNotifier { ''; } + String _currentVoiceRoomGroupId() { + final currentContext = context; + if (currentContext == null || !currentContext.mounted) { + return ''; + } + return Provider.of( + currentContext, + listen: false, + ).currenRoom?.roomProfile?.roomProfile?.roomAccount?.trim() ?? + ''; + } + + bool _shouldRouteUntrackedRoomRedPacketBroadcast( + String groupId, + V2TimMessage message, + ) { + if (groupId == _currentVoiceRoomGroupId()) { + return false; + } + final customData = message.customElem?.data; + if (customData == null || customData.isEmpty) { + return false; + } + try { + final decoded = json.decode(customData); + final payload = _broadcastPayloadMap(decoded); + return payload['type'] == SCRoomMsgType.roomRedPacket; + } catch (_) { + return false; + } + } + Map _broadcastPayloadMap(dynamic data) { if (data is Map) { return data; @@ -1902,11 +1945,16 @@ class RealTimeMessagingManager extends ChangeNotifier { } ///全服广播消息 - _newBroadCastMsgRecv(String groupID, V2TimMessage message) async { + _newBroadCastMsgRecv( + String groupID, + V2TimMessage message, { + bool forceRegionBroadcast = false, + }) async { try { String? customData = message.customElem?.data; if (customData != null && customData.isNotEmpty) { - final isRegionBroadcastGroup = groupID == roomRedPacketBroadcastGroupId; + final isRegionBroadcastGroup = + forceRegionBroadcast || groupID == roomRedPacketBroadcastGroupId; final data = json.decode(customData); var type = data["type"]; if (type == "SYS_ACTIVITY") { @@ -1988,7 +2036,25 @@ class RealTimeMessagingManager extends ChangeNotifier { } else if (type == SCRoomMsgType.roomRedPacket) { ///红包触发飘屏 var fData = data["data"]; + final payload = _broadcastPayloadMap(fData); + final packetId = _payloadText(payload['packetId']); + final packetRoomId = _payloadText(payload["roomId"]); + debugPrint( + '[RoomRedPacket][IM] recv broadcast packetId=$packetId ' + 'roomId=$packetRoomId group=$groupID ' + 'regionGroup=$roomRedPacketBroadcastGroupId ' + 'isRegion=$isRegionBroadcastGroup ' + 'payloadRegion=${_payloadText(payload['regionCode'])} ' + 'currentRegion=${roomRedPacketBroadcastRegionCode ?? ''} ' + 'currentRoom=${_currentVoiceRoomId()}', + ); if (!_isSameRoomRedPacketRegion(fData)) { + debugPrint( + '[RoomRedPacket][IM] skip broadcast region mismatch ' + 'packetId=$packetId ' + 'payloadRegion=${_payloadText(payload['regionCode'])} ' + 'currentRegion=${roomRedPacketBroadcastRegionCode ?? ''}', + ); return; } RoomRedPacketPendingCache.instance.upsertPayload(fData); @@ -2007,7 +2073,6 @@ class RealTimeMessagingManager extends ChangeNotifier { : '', priority: 1000, ); - final packetRoomId = _payloadText(fData["roomId"]); if (_isCurrentVisibleVoiceRoom(packetRoomId)) { final rtcProvider = Provider.of( context!, @@ -2616,6 +2681,12 @@ class RealTimeMessagingManager extends ChangeNotifier { return; } + debugPrint( + '[RoomRedPacket][IM] send broadcast packetId=${payload['packetId']} ' + 'roomId=${payload['roomId']} mode=${payload['packetMode']} ' + 'targets=${targetGroupIds.join(',')} regionGroup=$regionGroupId ' + 'region=${payload['regionCode'] ?? roomRedPacketBroadcastRegionCode ?? ''}', + ); final message = { 'type': SCRoomMsgType.roomRedPacket, 'data': payload, diff --git a/lib/services/audio/voice_room_foreground_service.dart b/lib/services/audio/voice_room_foreground_service.dart new file mode 100644 index 0000000..9e164b5 --- /dev/null +++ b/lib/services/audio/voice_room_foreground_service.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class SCVoiceRoomForegroundService { + static const MethodChannel _channel = MethodChannel( + 'com.org.yumiparty/voice_room_foreground_service', + ); + + static Future start({String? roomName}) async { + if (!Platform.isAndroid) { + return; + } + try { + await _channel.invokeMethod('start', { + 'roomName': roomName?.trim(), + }); + } on PlatformException catch (e) { + debugPrint('[VoiceRoomForeground] start failed: ${e.message}'); + } catch (e) { + debugPrint('[VoiceRoomForeground] start failed: $e'); + } + } + + static Future stop() async { + if (!Platform.isAndroid) { + return; + } + try { + await _channel.invokeMethod('stop'); + } on PlatformException catch (e) { + debugPrint('[VoiceRoomForeground] stop failed: ${e.message}'); + } catch (e) { + debugPrint('[VoiceRoomForeground] stop failed: $e'); + } + } +} diff --git a/lib/shared/data_sources/sources/local/floating_screen_manager.dart b/lib/shared/data_sources/sources/local/floating_screen_manager.dart index d19145a..0792c5e 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -24,12 +24,14 @@ typedef FloatingScreenManager = OverlayManager; class OverlayManager { static const int _maxLowPerformanceCompensationQueueLength = 8; + static const int _maxDeferredRegionRedPacketCount = 12; static const Duration _compensationDedupWindow = Duration(seconds: 3); static const Duration _redPacketFloatingDedupWindow = Duration(minutes: 30); final SCPriorityQueue _messageQueue = SCPriorityQueue( (a, b) => b.priority.compareTo(a.priority), ); + final List _deferredRegionRedPacketMessages = []; final Map _recentCompensationKeys = {}; final Map _recentFloatingKeys = {}; bool _isPlaying = false; @@ -53,6 +55,12 @@ class OverlayManager { if (_isRecentFloatingDuplicate(message)) { return; } + if (message.type == 4) { + debugPrint( + '[RoomRedPacket][Floating] enqueue packetId=${message.toUserId} ' + 'roomId=${message.roomId} region=${message.isRegionBroadcast}', + ); + } _rememberFloatingMessage(message); _enqueueMessage( message, @@ -61,6 +69,7 @@ class OverlayManager { } else { _removeActiveMessage(scheduleNext: false); _messageQueue.clear(); + _deferredRegionRedPacketMessages.clear(); _isPlaying = false; _isProcessing = false; _isDisposed = false; @@ -256,9 +265,20 @@ class OverlayManager { // 安全地获取第一个消息 if (_messageQueue.isEmpty) return; final messageToProcess = _messageQueue.first; + if (messageToProcess == null) { + _messageQueue.removeFirst(); + _safeScheduleNext(); + return; + } if (!_shouldDisplayMessage(context, messageToProcess)) { _messageQueue.removeFirst(); + if (_shouldDeferRegionRedPacketMessage(context, messageToProcess)) { + _deferRegionRedPacketMessage(context, messageToProcess); + _safeScheduleNext(); + return; + } + _logRegionRedPacketDrop(context, messageToProcess); _safeScheduleNext(); return; } @@ -401,6 +421,7 @@ class OverlayManager { _removeRegionBroadcastMessages(); return; } + _restoreDeferredRegionRedPacketMessages(); _safeScheduleNext(); } @@ -434,8 +455,10 @@ class OverlayManager { if (SCGlobalConfig.isFloatingBroadcastClosed) { _removeActiveMessage(scheduleNext: false); _messageQueue.clear(); + _deferredRegionRedPacketMessages.clear(); return; } + _restoreDeferredRegionRedPacketMessages(); final context = navigatorKey.currentState?.context; final activeMessage = _currentMessage; if (context != null && @@ -456,6 +479,7 @@ class OverlayManager { _currentOverlayEntry = null; _currentMessage = null; _messageQueue.clear(); + _deferredRegionRedPacketMessages.clear(); _recentCompensationKeys.clear(); _isPlaying = false; _isProcessing = false; @@ -497,6 +521,86 @@ class OverlayManager { bool get _isSuppressed => _suppressedCount > 0; + bool _isRegionRedPacketMessage(SCFloatingMessage message) { + return message.type == 4 && message.isRegionBroadcast; + } + + bool _shouldDeferRegionRedPacketMessage( + BuildContext context, + SCFloatingMessage message, + ) { + if (!_isRegionRedPacketMessage(message) || + SCGlobalConfig.isFloatingBroadcastClosed) { + return false; + } + return !_shouldDisplayRegionBroadcastMessage(context, message); + } + + void _deferRegionRedPacketMessage( + BuildContext context, + SCFloatingMessage message, + ) { + final key = _floatingDedupKeyFor(message); + if (key != null && + _deferredRegionRedPacketMessages.any( + (item) => _floatingDedupKeyFor(item) == key, + )) { + return; + } + while (_deferredRegionRedPacketMessages.length >= + _maxDeferredRegionRedPacketCount) { + _deferredRegionRedPacketMessages.removeAt(0); + } + _deferredRegionRedPacketMessages.add(message); + debugPrint( + '[RoomRedPacket][Floating] defer packetId=${message.toUserId} ' + 'roomId=${message.roomId} ${_displayStateForLog(context)}', + ); + } + + void _restoreDeferredRegionRedPacketMessages() { + if (_deferredRegionRedPacketMessages.isEmpty) { + return; + } + final messages = List.from( + _deferredRegionRedPacketMessages, + ); + _deferredRegionRedPacketMessages.clear(); + for (final message in messages) { + _messageQueue.add(message); + } + debugPrint( + '[RoomRedPacket][Floating] restore deferred count=${messages.length}', + ); + } + + void _logRegionRedPacketDrop( + BuildContext context, + SCFloatingMessage message, + ) { + if (message.type != 4) { + return; + } + debugPrint( + '[RoomRedPacket][Floating] drop packetId=${message.toUserId} ' + 'roomId=${message.roomId} region=${message.isRegionBroadcast} ' + '${_displayStateForLog(context)}', + ); + } + + String _displayStateForLog(BuildContext context) { + final rtcProvider = Provider.of( + context, + listen: false, + ); + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; + return 'homeVisible=$_homeRootTabsVisible ' + 'roomVisible=${rtcProvider.shouldShowRoomVisualEffects} ' + 'currentRoomId=$currentRoomId ' + 'scope=${SCGlobalConfig.floatingBroadcastScope}'; + } + bool _shouldDisplayMessage(BuildContext context, SCFloatingMessage? message) { if (message == null) { return false; @@ -582,7 +686,7 @@ class OverlayManager { while (_messageQueue.isNotEmpty) { final message = _messageQueue.removeFirst(); - if (!message.isRegionBroadcast) { + if (!message.isRegionBroadcast || _isRegionRedPacketMessage(message)) { newQueue.add(message); } } diff --git a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart index 2bdb475..9dea6ef 100644 --- a/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart +++ b/lib/ui_kit/widgets/room/red_packet/room_red_packet_send_panel.dart @@ -607,6 +607,11 @@ class _RoomRedPacketSendPanelState extends State { (_selectedPickupMinutes > 0 ? _selectedPickupMinutes * 60 : 0) ..['claimStartTime'] = claimStartTime ..['grabbed'] = result.grabbed ?? false; + final roomRegionCode = rtcProvider.currenRoom?.roomProfile?.regionCode; + if ((payload['regionCode']?.toString().trim() ?? '').isEmpty && + (roomRegionCode?.trim().isNotEmpty ?? false)) { + payload['regionCode'] = roomRegionCode!.trim(); + } final rtmProvider = Provider.of(context, listen: false); rtmProvider.addMsg( diff --git a/lib/ui_kit/widgets/room/room_play_widget.dart b/lib/ui_kit/widgets/room/room_play_widget.dart index 32667c0..3d30818 100644 --- a/lib/ui_kit/widgets/room/room_play_widget.dart +++ b/lib/ui_kit/widgets/room/room_play_widget.dart @@ -68,6 +68,7 @@ class _RoomRedPacketFloatingEntryState Timer? _entryRefreshTimer; bool _refreshingList = false; int _elapsedSeconds = 0; + String _observedRoomId = ''; String _verifiedRoomId = ''; int _verifiedRoomRefreshCount = 0; Set _verifiedKnownPacketIds = const {}; @@ -80,6 +81,11 @@ class _RoomRedPacketFloatingEntryState const Duration(seconds: 1), (_) => _onEntryTimerTick(), ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _refreshRoomRedPacketList(); + } + }); } @override @@ -93,6 +99,7 @@ class _RoomRedPacketFloatingEntryState if (!mounted) { return; } + _refreshRoomRedPacketListIfRoomChanged(); setState(() {}); _elapsedSeconds++; if (_elapsedSeconds % _listRefreshIntervalSeconds == 0) { @@ -100,6 +107,24 @@ class _RoomRedPacketFloatingEntryState } } + void _refreshRoomRedPacketListIfRoomChanged() { + try { + final rtcProvider = Provider.of(context, listen: false); + final currentRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ''; + if (currentRoomId.isEmpty || currentRoomId == _observedRoomId) { + return; + } + _observedRoomId = currentRoomId; + _elapsedSeconds = 0; + _verifiedRoomId = ''; + _verifiedRoomRefreshCount = 0; + _verifiedKnownPacketIds = const {}; + _verifiedVisiblePacketIds = const {}; + _refreshRoomRedPacketList(); + } catch (_) {} + } + void _refreshRoomRedPacketList() { if (_refreshingList || !mounted) { return; @@ -116,6 +141,13 @@ class _RoomRedPacketFloatingEntryState rtcProvider .loadRoomRedPacketList(1) .then((result) { + final activeRoomId = + rtcProvider.currenRoom?.roomProfile?.roomProfile?.id + ?.trim() ?? + ''; + if (activeRoomId != currentRoomId) { + return result; + } final serverPackets = result.map(RoomRedPacketUiData.fromListRes).toList(); final serverPacketIds = diff --git a/pubspec.yaml b/pubspec.yaml index 7ddbebe..a60a2f8 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.3.0+10 +version: 1.3.0+11 environment: diff --git a/需求进度.md b/需求进度.md index 29fcae4..ecd7676 100644 --- a/需求进度.md +++ b/需求进度.md @@ -562,6 +562,7 @@ - 已修正红包领取成功公屏消息的多语言问题:领取后不再发送已翻译好的普通文本,改为 `ROOM_RED_PACKET_CLAIM` 结构化房间 IM,只传领取金额、红包发送人名和 `packetId`,公屏渲染时按当前客户端语言生成文案,避免英文用户看到阿语消息。 - 已补强房外延时红包飘屏和领取通知去重:首页四个一级 Tab 可见/回到前台时会重新同步当前语区红包 IM 群;语区红包飘屏在首页 Tab 中不再受“只看房间飘屏”设置影响;领取成功公屏通知按 `packetId + 领取人ID` 生成稳定去重键,避免本地插入和 IM 回包/历史拉取造成两条一模一样的领取消息。 - 已定位并修复公屏上滑历史反复弹 `user info not found`:历史消息 item 重建时会重复调用用户资料接口,缺失用户返回错误后被网络层 toast;现在房间公屏补拉用户资料改为静默请求,并对同一 userId 的请求/失败结果做缓存,5 分钟内不再反复请求同一个不存在用户。 +- 已继续排查房外/跨房红包飘屏缺失:IM 重登成功后会立即重新同步当前语区红包群;收到未登记但内容为 `ROOM_RED_PACKET` 的群消息时会按语区红包飘屏兜底处理,避免语区群 id 尚未写入时被普通群消息分支吞掉;区域红包在首页 Tab 暂不可见时不再直接从飘屏队列丢弃,而是挂起后在回到首页 Tab 时恢复播放;发送红包 payload 也会补齐当前房间 `regionCode`,便于日志核对和前端兜底过滤。 ## 已知问题 - 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji` 与 `monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。