diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart index 0ee35b1..764c66c 100644 --- a/lib/modules/room/seat/sc_seat_item.dart +++ b/lib/modules/room/seat/sc_seat_item.dart @@ -231,7 +231,7 @@ class _SeatRenderSnapshot { }); factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) { - final roomSeat = provider.roomWheatMap[index]; + final roomSeat = provider.micAtIndexForDisplay(index); final user = roomSeat?.user; return _SeatRenderSnapshot( isExitingCurrentVoiceRoomSession: @@ -398,8 +398,9 @@ class _EmoticonsState extends State with TickerProviderStateMixin { Widget build(BuildContext context) { return Selector( selector: - (context, provider) => - _SeatEmojiSnapshot.fromMic(provider.roomWheatMap[widget.index]), + (context, provider) => _SeatEmojiSnapshot.fromMic( + provider.micAtIndexForDisplay(widget.index), + ), builder: ( BuildContext context, _SeatEmojiSnapshot snapshot, diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index dbb4dc6..ed6011f 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -14,7 +14,9 @@ import 'package:yumi/services/gift/gift_animation_manager.dart'; import 'package:yumi/services/gift/gift_system_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; +import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart'; import 'package:yumi/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart'; import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_screen.dart'; @@ -138,6 +140,7 @@ class _VoiceRoomPageState extends State RoomEntranceHelper.clearQueue(); _clearLuckyGiftComboSessions(); _giftSeatFlightController.clear(); + SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend'); SCGiftVapSvgaManager().stopPlayback(); } @@ -355,6 +358,21 @@ class _VoiceRoomPageState extends State giftModel.sendUserPic = msg.user?.userAvatar ?? ""; giftModel.giftPic = msg.gift?.giftPhoto ?? ""; giftModel.giftCount = msg.number ?? 0; + giftModel.giftCountStepUnit = _resolveGiftCountStepUnit(msg); + unawaited( + warmImageResource( + giftModel.sendUserPic, + logicalWidth: 26.w, + logicalHeight: 26.w, + ), + ); + unawaited( + warmImageResource( + giftModel.giftPic, + logicalWidth: 34.w, + logicalHeight: 34.w, + ), + ); Provider.of( context, listen: false, @@ -398,6 +416,20 @@ class _VoiceRoomPageState extends State giftModel.rewardAmount = awardAmount; giftModel.showLuckyRewardFrame = true; giftModel.rewardAmountText = _formatLuckyRewardAmount(awardAmount); + unawaited( + warmImageResource( + giftModel.sendUserPic, + logicalWidth: 26.w, + logicalHeight: 26.w, + ), + ); + unawaited( + warmImageResource( + giftModel.giftPic, + logicalWidth: 34.w, + logicalHeight: 34.w, + ), + ); Provider.of( context, listen: false, @@ -443,11 +475,13 @@ class _VoiceRoomPageState extends State ); session.endTimer?.cancel(); session.clearQueueTimer?.cancel(); + final previousTotal = session.totalCount; session.totalCount += quantity; final highestMilestone = - SocialChatGiftSystemManager.resolveHighestReachedComboMilestone( - session.totalCount, + SocialChatGiftSystemManager.resolveHighestCrossedComboEffectMilestone( + previousCount: previousTotal, + currentCount: session.totalCount, ); if (highestMilestone != null && highestMilestone > session.highestPlayedMilestone && @@ -527,12 +561,28 @@ class _VoiceRoomPageState extends State return math.max(msg.customAnimationCount ?? 1, 1); } + num _resolveGiftCountStepUnit(Msg msg) { + final quantity = msg.number ?? 0; + final clickCount = math.max(msg.customAnimationCount ?? 1, 1); + if (quantity <= 0) { + return 1; + } + final stepUnit = quantity / clickCount; + if (stepUnit <= 0) { + return quantity; + } + return stepUnit; + } + void _enqueueTrackedSeatFlightAnimations({ required String sessionKey, required String giftPhoto, required String targetUserId, required int animationCount, }) { + unawaited( + warmImageResource(giftPhoto, logicalWidth: 96.w, logicalHeight: 96.w), + ); final normalizedAnimationCount = math.max(animationCount, 1); final cappedAnimationCount = math.min( normalizedAnimationCount, diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 0b7683d..450224e 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -48,7 +48,7 @@ typedef RtcProvider = RealTimeCommunicationManager; class RealTimeCommunicationManager extends ChangeNotifier { static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); - static const Duration _selfMicSwitchGracePeriod = Duration(seconds: 4); + static const Duration _selfMicStateGracePeriod = Duration(seconds: 4); static const Duration _giftTriggeredMicRefreshMinInterval = Duration( milliseconds: 900, ); @@ -66,6 +66,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { num? _preferredSelfMicIndex; num? _pendingSelfMicSourceIndex; int? _pendingSelfMicSwitchGuardUntilMs; + num? _pendingSelfMicReleaseIndex; + int? _pendingSelfMicReleaseGuardUntilMs; ClientRoleType? _lastAppliedClientRole; bool? _lastAppliedLocalAudioMuted; bool? _lastScheduledVoiceLiveOnMic; @@ -285,10 +287,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { required num sourceIndex, required num targetIndex, }) { + _clearSelfMicReleaseGuard(); _preferredSelfMicIndex = targetIndex; _pendingSelfMicSourceIndex = sourceIndex; _pendingSelfMicSwitchGuardUntilMs = - DateTime.now().add(_selfMicSwitchGracePeriod).millisecondsSinceEpoch; + DateTime.now().add(_selfMicStateGracePeriod).millisecondsSinceEpoch; } void _clearSelfMicSwitchGuard({bool clearPreferredIndex = false}) { @@ -299,6 +302,41 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } + 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); + } + Map _stabilizeSelfMicSnapshot( Map nextMap, { required Map previousMap, @@ -308,14 +346,13 @@ class RealTimeCommunicationManager extends ChangeNotifier { 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 > DateTime.now().millisecondsSinceEpoch; + targetIndex != null && sourceIndex != null && guardUntilMs > nowMs; final selfSeatIndices = nextMap.entries .where((entry) => entry.value.user?.id == currentUserId) @@ -330,63 +367,90 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (!hasActiveGuard) { _clearSelfMicSwitchGuard(); - return nextMap; - } - final resolvedTargetIndex = targetIndex; - final resolvedSourceIndex = sourceIndex; + } else { + final resolvedTargetIndex = targetIndex; + final resolvedSourceIndex = sourceIndex; - final selfOnlyOnSource = - selfSeatIndices.isEmpty || - selfSeatIndices.every((seatIndex) => seatIndex == resolvedSourceIndex); - if (!selfOnlyOnSource) { + 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.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; } - final optimisticTargetSeat = previousMap[resolvedTargetIndex]; - if (optimisticTargetSeat == null || - optimisticTargetSeat.user?.id != currentUserId) { + if (selfSeatIndices.isEmpty) { return nextMap; } - final incomingTargetSeat = nextMap[resolvedTargetIndex]; - if (incomingTargetSeat?.user != null && - incomingTargetSeat?.user?.id != currentUserId) { + if (selfSeatIndices.any((seatIndex) => seatIndex != releaseIndex)) { + _clearSelfMicReleaseGuard(); + return nextMap; + } + + final releaseSeat = nextMap[releaseIndex]; + if (releaseSeat?.user?.id != currentUserId) { + _clearSelfMicReleaseGuard(); return nextMap; } final stabilizedMap = Map.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, - ); - + stabilizedMap[releaseIndex] = releaseSeat!.copyWith(clearUser: true); return stabilizedMap; } @@ -1208,6 +1272,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { needUpDataUserInfo = false; SCRoomUtils.roomUsersMap.clear(); _clearSelfMicSwitchGuard(clearPreferredIndex: true); + _clearSelfMicReleaseGuard(); roomIsMute = false; rtmProvider?.roomAllMsgList.clear(); rtmProvider?.roomChatMsgList.clear(); @@ -1405,6 +1470,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { final isSeatSwitching = previousSelfSeatIndex > -1 && previousSelfSeatIndex != targetIndex; if (myUser != null) { + _clearSelfMicReleaseGuard(); if (isSeatSwitching) { _startSelfMicSwitchGuard( sourceIndex: previousSelfSeatIndex, @@ -1454,20 +1520,30 @@ class RealTimeCommunicationManager extends ChangeNotifier { index, ); - if (roomWheatMap[index]?.user?.id == - AccountStorage().getCurrentUser()?.userProfile?.id) { + final currentUserId = + AccountStorage().getCurrentUser()?.userProfile?.id ?? ""; + final currentUserSeatIndex = userOnMaiInIndex(currentUserId); + if (roomWheatMap[index]?.user?.id == currentUserId) { isMic = true; engine?.muteLocalAudioStream(true); } _clearSelfMicSwitchGuard(clearPreferredIndex: true); + if (currentUserSeatIndex > -1) { + _startSelfMicReleaseGuard(sourceIndex: currentUserSeatIndex); + } else { + _clearSelfMicReleaseGuard(); + } SCHeartbeatUtils.cancelAnchorTimer(); /// 设置成主持人角色 engine?.renewToken(""); engine?.setClientRole(role: ClientRoleType.clientRoleAudience); - _clearUserFromSeats(AccountStorage().getCurrentUser()?.userProfile?.id); + _clearUserFromSeats(currentUserId); notifyListeners(); - _refreshMicListSilently(); + requestMicrophoneListRefresh( + notifyIfUnchanged: false, + minInterval: const Duration(milliseconds: 350), + ); } catch (ex) { SCTts.show('Failed to leave the microphone, $ex'); } @@ -1510,23 +1586,41 @@ class RealTimeCommunicationManager extends ChangeNotifier { ///自己是否在麦上 bool isOnMai() { - return roomWheatMap.values - .map((userWheat) => userWheat.user?.id) - .toList() - .contains(AccountStorage().getCurrentUser()?.userProfile?.id); + 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 roomWheatMap[index]?.user?.id == - AccountStorage().getCurrentUser()?.userProfile?.id; + 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) { - if (value.user?.id == userId) { + final visibleSeat = + normalizedUserId == + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "") + .trim() + ? micAtIndexForDisplay(k) + : value; + if ((visibleSeat?.user?.id ?? "").trim() == normalizedUserId) { index = k; } }); diff --git a/lib/services/gift/gift_animation_manager.dart b/lib/services/gift/gift_animation_manager.dart index 8b93f74..576ea2a 100644 --- a/lib/services/gift/gift_animation_manager.dart +++ b/lib/services/gift/gift_animation_manager.dart @@ -1,140 +1,279 @@ -import 'dart:collection'; - -import 'package:flutter/cupertino.dart'; - -import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart'; - -class GiftAnimationManager extends ChangeNotifier { - static const int _maxPendingAnimations = 24; - - Queue pendingAnimationsQueue = Queue(); - List animationControllerList = []; - - //每个控件正在播放的动画 - Map giftMap = {0: null, 1: null, 2: null, 3: null}; - - bool get _controllersReady => - animationControllerList.length >= giftMap.length; - - void enqueueGiftAnimation(LGiftModel giftModel) { - if (_mergeIntoActiveAnimation(giftModel)) { - return; - } - if (_mergeIntoPendingAnimation(giftModel)) { - return; - } - _trimPendingAnimations(); - pendingAnimationsQueue.add(giftModel); - proceedToNextAnimation(); - } - - void _trimPendingAnimations() { - while (pendingAnimationsQueue.length >= _maxPendingAnimations) { - pendingAnimationsQueue.removeFirst(); - } - } - - bool _mergeIntoActiveAnimation(LGiftModel incoming) { - for (final entry in giftMap.entries) { - final current = entry.value; - if (current == null || current.labelId != incoming.labelId) { - continue; - } - _mergeGiftModel(target: current, incoming: incoming); - notifyListeners(); - if (animationControllerList.length > entry.key) { - animationControllerList[entry.key].controller.forward(from: 0.45); - } - return true; - } - return false; - } - - bool _mergeIntoPendingAnimation(LGiftModel incoming) { - for (final pending in pendingAnimationsQueue) { - if (pending.labelId != incoming.labelId) { - continue; - } - _mergeGiftModel(target: pending, incoming: incoming); - return true; - } - return false; - } - - void _mergeGiftModel({ - required LGiftModel target, - required LGiftModel incoming, - }) { - target.giftCount = (target.giftCount) + (incoming.giftCount); - if (target.sendUserName.isEmpty) { - target.sendUserName = incoming.sendUserName; - } - if (target.sendToUserName.isEmpty) { - target.sendToUserName = incoming.sendToUserName; - } - if (target.sendUserPic.isEmpty) { - target.sendUserPic = incoming.sendUserPic; - } - if (target.giftPic.isEmpty) { - target.giftPic = incoming.giftPic; - } - if (target.giftName.isEmpty) { - target.giftName = incoming.giftName; - } - target.rewardAmount = target.rewardAmount + incoming.rewardAmount; - if (incoming.rewardAmountText.isNotEmpty) { - target.rewardAmountText = incoming.rewardAmountText; - } - target.showLuckyRewardFrame = - target.showLuckyRewardFrame || incoming.showLuckyRewardFrame; - } - - ///开始播放 - proceedToNextAnimation() { - if (pendingAnimationsQueue.isEmpty || !_controllersReady) { - return; - } - var playGift = pendingAnimationsQueue.first; - for (var key in giftMap.keys) { - var value = giftMap[key]; - if (value == null) { - giftMap[key] = playGift; - pendingAnimationsQueue.removeFirst(); - notifyListeners(); - animationControllerList[key].controller.forward(from: 0); - break; - } else { - if (value.labelId == playGift.labelId) { - _mergeGiftModel(target: value, incoming: playGift); - pendingAnimationsQueue.removeFirst(); - notifyListeners(); - animationControllerList[key].controller.forward(from: 0.45); - break; - } - } - } - } - - void attachAnimationControllers(List anins) { - animationControllerList = anins; - proceedToNextAnimation(); - } - - void cleanupAnimationResources() { - pendingAnimationsQueue.clear(); - giftMap[0] = null; - giftMap[1] = null; - giftMap[2] = null; - giftMap[3] = null; - for (var element in animationControllerList) { - element.controller.dispose(); - } - animationControllerList.clear(); - } - - void markAnimationAsFinished(int index) { - giftMap[index] = null; - notifyListeners(); - proceedToNextAnimation(); - } -} +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/cupertino.dart'; + +import 'package:yumi/ui_kit/widgets/room/anim/l_gift_animal_view.dart'; + +class GiftAnimationManager extends ChangeNotifier { + static const int _maxPendingAnimations = 24; + static const Duration _activeComboIdleDismissDelay = Duration( + milliseconds: 3200, + ); + + Queue pendingAnimationsQueue = Queue(); + List animationControllerList = []; + + //每个控件正在播放的动画 + Map giftMap = {0: null, 1: null, 2: null, 3: null}; + + bool get _controllersReady => + animationControllerList.length >= giftMap.length; + + GiftAnimationSlotSnapshot? slotSnapshotAt(int index) { + final gift = giftMap[index]; + if (gift == null || animationControllerList.length <= index) { + return null; + } + final bean = animationControllerList[index]; + return GiftAnimationSlotSnapshot( + index: index, + bean: bean, + sendUserName: gift.sendUserName, + sendToUserName: gift.sendToUserName, + sendUserPic: gift.sendUserPic, + giftPic: gift.giftPic, + giftName: gift.giftName, + giftCount: gift.giftCount, + rewardAmountText: gift.rewardAmountText, + rewardAmount: gift.rewardAmount, + showLuckyRewardFrame: gift.showLuckyRewardFrame, + labelId: gift.labelId, + giftCountStepUnit: gift.giftCountStepUnit, + ); + } + + void enqueueGiftAnimation(LGiftModel giftModel) { + if (_mergeIntoActiveAnimation(giftModel)) { + return; + } + if (_mergeIntoPendingAnimation(giftModel)) { + return; + } + _trimPendingAnimations(); + pendingAnimationsQueue.add(giftModel); + proceedToNextAnimation(); + } + + void _trimPendingAnimations() { + while (pendingAnimationsQueue.length >= _maxPendingAnimations) { + pendingAnimationsQueue.removeFirst(); + } + } + + bool _mergeIntoActiveAnimation(LGiftModel incoming) { + for (final entry in giftMap.entries) { + final current = entry.value; + if (current == null || current.labelId != incoming.labelId) { + continue; + } + _mergeGiftModel(target: current, incoming: incoming); + notifyListeners(); + _refreshSlotAnimation(entry.key, restartEntry: false); + return true; + } + return false; + } + + bool _mergeIntoPendingAnimation(LGiftModel incoming) { + for (final pending in pendingAnimationsQueue) { + if (pending.labelId != incoming.labelId) { + continue; + } + _mergeGiftModel(target: pending, incoming: incoming); + return true; + } + return false; + } + + void _mergeGiftModel({ + required LGiftModel target, + required LGiftModel incoming, + }) { + target.giftCount = (target.giftCount) + (incoming.giftCount); + if (target.sendUserName.isEmpty) { + target.sendUserName = incoming.sendUserName; + } + if (target.sendToUserName.isEmpty) { + target.sendToUserName = incoming.sendToUserName; + } + if (target.sendUserPic.isEmpty) { + target.sendUserPic = incoming.sendUserPic; + } + if (target.giftPic.isEmpty) { + target.giftPic = incoming.giftPic; + } + if (target.giftName.isEmpty) { + target.giftName = incoming.giftName; + } + target.rewardAmount = target.rewardAmount + incoming.rewardAmount; + if (incoming.rewardAmountText.isNotEmpty) { + target.rewardAmountText = incoming.rewardAmountText; + } + target.showLuckyRewardFrame = + target.showLuckyRewardFrame || incoming.showLuckyRewardFrame; + if (incoming.giftCountStepUnit > 0) { + target.giftCountStepUnit = incoming.giftCountStepUnit; + } + } + + ///开始播放 + proceedToNextAnimation() { + if (pendingAnimationsQueue.isEmpty || !_controllersReady) { + return; + } + var playGift = pendingAnimationsQueue.first; + for (var key in giftMap.keys) { + var value = giftMap[key]; + if (value == null) { + giftMap[key] = playGift; + pendingAnimationsQueue.removeFirst(); + notifyListeners(); + _refreshSlotAnimation(key, restartEntry: true); + break; + } else { + if (value.labelId == playGift.labelId) { + _mergeGiftModel(target: value, incoming: playGift); + pendingAnimationsQueue.removeFirst(); + notifyListeners(); + _refreshSlotAnimation(key, restartEntry: false); + break; + } + } + } + } + + void attachAnimationControllers(List anins) { + animationControllerList = anins; + proceedToNextAnimation(); + } + + void cleanupAnimationResources() { + pendingAnimationsQueue.clear(); + giftMap[0] = null; + giftMap[1] = null; + giftMap[2] = null; + giftMap[3] = null; + for (var element in animationControllerList) { + element.dismissTimer?.cancel(); + element.controller.dispose(); + element.countPulseController.dispose(); + } + animationControllerList.clear(); + } + + void markAnimationAsFinished(int index) { + _cancelSlotDismissTimer(index); + if (giftMap[index] == null) { + return; + } + giftMap[index] = null; + notifyListeners(); + proceedToNextAnimation(); + } + + void _refreshSlotAnimation(int index, {required bool restartEntry}) { + if (animationControllerList.length <= index) { + return; + } + final bean = animationControllerList[index]; + _restartSlotDismissTimer(index); + bean.countPulseController.forward(from: 0); + if (restartEntry) { + bean.controller.forward(from: 0); + } + } + + void _restartSlotDismissTimer(int index) { + if (animationControllerList.length <= index) { + return; + } + final bean = animationControllerList[index]; + bean.dismissTimer?.cancel(); + bean.dismissTimer = Timer(_activeComboIdleDismissDelay, () { + if (giftMap[index] == null) { + return; + } + markAnimationAsFinished(index); + }); + } + + void _cancelSlotDismissTimer(int index) { + if (animationControllerList.length <= index) { + return; + } + animationControllerList[index].dismissTimer?.cancel(); + animationControllerList[index].dismissTimer = null; + } +} + +@immutable +class GiftAnimationSlotSnapshot { + const GiftAnimationSlotSnapshot({ + required this.index, + required this.bean, + required this.sendUserName, + required this.sendToUserName, + required this.sendUserPic, + required this.giftPic, + required this.giftName, + required this.giftCount, + required this.rewardAmountText, + required this.rewardAmount, + required this.showLuckyRewardFrame, + required this.labelId, + required this.giftCountStepUnit, + }); + + final int index; + final LGiftScrollingScreenAnimsBean bean; + final String sendUserName; + final String sendToUserName; + final String sendUserPic; + final String giftPic; + final String giftName; + final num giftCount; + final String rewardAmountText; + final num rewardAmount; + final bool showLuckyRewardFrame; + final String labelId; + final num giftCountStepUnit; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is GiftAnimationSlotSnapshot && + other.index == index && + identical(other.bean, bean) && + other.sendUserName == sendUserName && + other.sendToUserName == sendToUserName && + other.sendUserPic == sendUserPic && + other.giftPic == giftPic && + other.giftName == giftName && + other.giftCount == giftCount && + other.rewardAmountText == rewardAmountText && + other.rewardAmount == rewardAmount && + other.showLuckyRewardFrame == showLuckyRewardFrame && + other.labelId == labelId && + other.giftCountStepUnit == giftCountStepUnit; + } + + @override + int get hashCode => Object.hash( + index, + bean, + sendUserName, + sendToUserName, + sendUserPic, + giftPic, + giftName, + giftCount, + rewardAmountText, + rewardAmount, + showLuckyRewardFrame, + labelId, + giftCountStepUnit, + ); +} diff --git a/lib/services/gift/gift_system_manager.dart b/lib/services/gift/gift_system_manager.dart index a02080d..2bc53e9 100644 --- a/lib/services/gift/gift_system_manager.dart +++ b/lib/services/gift/gift_system_manager.dart @@ -27,6 +27,8 @@ class SocialChatGiftSystemManager extends ChangeNotifier { 8000: "sc_images/room/anim/luck_gift/luck_gift_combo_count_8000.svga", 10000: "sc_images/room/anim/luck_gift/luck_gift_combo_count_10000.svga", }; + static final List _luckGiftComboEffectMilestones = + (_luckGiftComboEffectAssets.keys.toList()..sort()); static const List _luckGiftMilestones = [ 10, 20, @@ -211,7 +213,7 @@ class SocialChatGiftSystemManager extends ChangeNotifier { } void startGiftAnimation() { - final milestone = resolveHighestReachedComboMilestone(number); + final milestone = resolveHighestReachedComboEffectMilestone(number); if (milestone == null || (isPlayed[milestone] ?? false)) { return; } @@ -261,19 +263,45 @@ class SocialChatGiftSystemManager extends ChangeNotifier { return resolveHighestReachedComboMilestone(count); } + static int? resolveHighestReachedComboEffectMilestone(num count) { + final normalizedCount = count.floor(); + int? highest; + for (final milestone in _luckGiftComboEffectMilestones) { + if (milestone > normalizedCount) { + break; + } + highest = milestone; + } + return highest; + } + + static int? resolveHighestCrossedComboEffectMilestone({ + required num previousCount, + required num currentCount, + }) { + final previous = previousCount.floor(); + final current = currentCount.floor(); + if (current <= previous) { + return null; + } + int? highest; + for (final milestone in _luckGiftComboEffectMilestones) { + if (milestone <= previous) { + continue; + } + if (milestone > current) { + break; + } + highest = milestone; + } + return highest; + } + static String? resolveComboMilestoneEffectPath(num count) { if (count % 1 != 0) { return null; } - final normalizedCount = count.toInt(); - final comboEffectPath = _luckGiftComboEffectAssets[normalizedCount]; - if (comboEffectPath != null) { - return comboEffectPath; - } - if (normalizedCount > 9999) { - return "sc_images/room/anim/luck_gift_count_5000_mor.mp4"; - } - return "sc_images/room/anim/luck_gift_count_$normalizedCount.mp4"; + return _luckGiftComboEffectAssets[count.toInt()]; } static String? resolveLuckGiftComboEffectPath(num count) { 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 5bfd0a0..0d84401 100644 --- a/lib/shared/data_sources/sources/local/floating_screen_manager.dart +++ b/lib/shared/data_sources/sources/local/floating_screen_manager.dart @@ -1,213 +1,250 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/app/constants/sc_global_config.dart'; -import 'package:yumi/main.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/shared/tools/sc_entrance_vap_svga_manager.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/floating/floating_game_screen_widget.dart'; -import 'package:yumi/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart'; -import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart'; -import 'package:yumi/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart'; -import 'package:yumi/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart'; -import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; - -typedef FloatingScreenManager = OverlayManager; - -class OverlayManager { - final SCPriorityQueue _messageQueue = SCPriorityQueue( - (a, b) => b.priority.compareTo(a.priority), - ); - bool _isPlaying = false; - OverlayEntry? _currentOverlayEntry; - - bool _isProcessing = false; - bool _isDisposed = false; - - static final OverlayManager _instance = OverlayManager._internal(); - - factory OverlayManager() => _instance; - - OverlayManager._internal(); - - void addMessage(SCFloatingMessage message) { - if (_isDisposed) return; - if (SCGlobalConfig.isFloatingAnimationInGlobal) { - _messageQueue.add(message); - _safeScheduleNext(); - } else { - _messageQueue.clear(); - _isPlaying = false; - _isProcessing = false; - _isDisposed = false; - } - } - - void _safeScheduleNext() { - if (_isProcessing || _isPlaying || _messageQueue.isEmpty) return; - _isProcessing = true; - - try { - _scheduleNext(); - } finally { - _isProcessing = false; - } - } - - void _scheduleNext() { - if (_isPlaying || _messageQueue.isEmpty || _isDisposed) return; - - try { - final context = navigatorKey.currentState?.context; - if (context == null || !context.mounted) return; - - // 安全地获取第一个消息 - if (_messageQueue.isEmpty) return; - final messageToProcess = _messageQueue.first; - - if (messageToProcess?.type == 1) { - final rtcProvider = Provider.of( - context, - listen: false, - ); - if (rtcProvider.currenRoom == null) { - // 从队列中移除第一个元素 - _messageQueue.removeFirst(); - _safeScheduleNext(); - return; - } - } - - // 安全地移除并播放消息 - final messageToPlay = _messageQueue.removeFirst(); - _playMessage(messageToPlay); - } catch (e) { - debugPrint('播放悬浮消息出错: $e'); - _isPlaying = false; - _safeScheduleNext(); - } - } - - void _playMessage(SCFloatingMessage message) { - _isPlaying = true; - final context = navigatorKey.currentState?.context; - if (context == null || !context.mounted) { - _isPlaying = false; - _safeScheduleNext(); - return; - } - - _currentOverlayEntry = OverlayEntry( - builder: - (_) => Align( - alignment: AlignmentDirectional.topStart, - child: Transform.translate( - offset: Offset(0, 70.w), - child: _buildScreenWidget(message), - ), - ), - ); - - Overlay.of(context).insert(_currentOverlayEntry!); - } - - Widget _buildScreenWidget(SCFloatingMessage message) { - bool completed = false; - - void onComplete() { - if (completed) return; - completed = true; - - try { - _currentOverlayEntry?.remove(); - _currentOverlayEntry = null; - _isPlaying = false; - _safeScheduleNext(); - } catch (e) { - debugPrint('清理悬浮消息出错: $e'); - _isPlaying = false; - _safeScheduleNext(); - } - } - - switch (message.type) { - case 0: - return FloatingLuckGiftScreenWidget( - message: message, - onAnimationCompleted: onComplete, - ); - case 1: - //房间礼物 - return FloatingGiftScreenWidget( - message: message, - onAnimationCompleted: onComplete, - ); - case 2: - //游戏中奖 - return FloatingGameScreenWidget( - message: message, - onAnimationCompleted: onComplete, - ); - case 3: - //火箭 - return FloatingRoomRocketScreenWidget( - message: message, - onAnimationCompleted: onComplete, - ); - case 4: - //红包 - return FloatingRoomRedenvelopeScreenWidget( - message: message, - onAnimationCompleted: onComplete, - ); - default: - onComplete(); - return Container(); - } - } - - void activate() { - _isDisposed = false; - } - - void dispose() { - _isDisposed = true; - _currentOverlayEntry?.remove(); - _currentOverlayEntry = null; - _messageQueue.clear(); - _isPlaying = false; - _isProcessing = false; - } - - void removeRoom() { - // 正确地从 PriorityQueue 中移除特定类型的消息 - _removeMessagesByType(1); - } - - // 辅助方法:移除特定类型的消息 - void _removeMessagesByType(int type) { - // 由于 PriorityQueue 没有直接的方法来移除特定元素, - // 我们需要重建队列,排除指定类型的消息 - final newQueue = SCPriorityQueue( - (a, b) => b.priority.compareTo(a.priority), - ); - - // 遍历原始队列,只添加非指定类型的消息 - while (_messageQueue.isNotEmpty) { - final message = _messageQueue.removeFirst(); - if (message.type != type) { - newQueue.add(message); - } - } - - // 将新队列赋值给原队列 - while (newQueue.isNotEmpty) { - _messageQueue.add(newQueue.removeFirst()); - } - } - - // 辅助方法:获取队列信息 - int get queueLength => _messageQueue.length; - - bool get isPlaying => _isPlaying; -} +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/main.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; +import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; +import 'package:yumi/shared/tools/sc_entrance_vap_svga_manager.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_game_screen_widget.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_room_redenvelope_screen_widget.dart'; +import 'package:yumi/ui_kit/widgets/room/floating/floating_room_rocket_screen_widget.dart'; +import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart'; + +typedef FloatingScreenManager = OverlayManager; + +class OverlayManager { + final SCPriorityQueue _messageQueue = SCPriorityQueue( + (a, b) => b.priority.compareTo(a.priority), + ); + bool _isPlaying = false; + OverlayEntry? _currentOverlayEntry; + + bool _isProcessing = false; + bool _isDisposed = false; + + static final OverlayManager _instance = OverlayManager._internal(); + + factory OverlayManager() => _instance; + + OverlayManager._internal(); + + void addMessage(SCFloatingMessage message) { + if (_isDisposed) return; + if (SCGlobalConfig.isFloatingAnimationInGlobal) { + unawaited(_warmMessageImages(message)); + SCRoomEffectScheduler().scheduleDeferredEffect( + debugLabel: 'floating_message_type_${message.type ?? -1}', + action: () { + if (_isDisposed) { + return; + } + _messageQueue.add(message); + _safeScheduleNext(); + }, + ); + } else { + _messageQueue.clear(); + _isPlaying = false; + _isProcessing = false; + _isDisposed = false; + } + } + + Future _warmMessageImages(SCFloatingMessage message) async { + unawaited( + warmImageResource( + message.userAvatarUrl ?? "", + logicalWidth: 80, + logicalHeight: 80, + ), + ); + unawaited( + warmImageResource( + message.toUserAvatarUrl ?? "", + logicalWidth: 80, + logicalHeight: 80, + ), + ); + unawaited( + warmImageResource( + message.giftUrl ?? "", + logicalWidth: 96, + logicalHeight: 96, + ), + ); + } + + void _safeScheduleNext() { + if (_isProcessing || _isPlaying || _messageQueue.isEmpty) return; + _isProcessing = true; + + try { + _scheduleNext(); + } finally { + _isProcessing = false; + } + } + + void _scheduleNext() { + if (_isPlaying || _messageQueue.isEmpty || _isDisposed) return; + + try { + final context = navigatorKey.currentState?.context; + if (context == null || !context.mounted) return; + + // 安全地获取第一个消息 + if (_messageQueue.isEmpty) return; + final messageToProcess = _messageQueue.first; + + if (messageToProcess?.type == 1) { + final rtcProvider = Provider.of( + context, + listen: false, + ); + if (rtcProvider.currenRoom == null) { + // 从队列中移除第一个元素 + _messageQueue.removeFirst(); + _safeScheduleNext(); + return; + } + } + + // 安全地移除并播放消息 + final messageToPlay = _messageQueue.removeFirst(); + _playMessage(messageToPlay); + } catch (e) { + debugPrint('播放悬浮消息出错: $e'); + _isPlaying = false; + _safeScheduleNext(); + } + } + + void _playMessage(SCFloatingMessage message) { + _isPlaying = true; + final context = navigatorKey.currentState?.context; + if (context == null || !context.mounted) { + _isPlaying = false; + _safeScheduleNext(); + return; + } + + _currentOverlayEntry = OverlayEntry( + builder: + (_) => Align( + alignment: AlignmentDirectional.topStart, + child: Transform.translate( + offset: Offset(0, 70.w), + child: RepaintBoundary(child: _buildScreenWidget(message)), + ), + ), + ); + + Overlay.of(context).insert(_currentOverlayEntry!); + } + + Widget _buildScreenWidget(SCFloatingMessage message) { + bool completed = false; + + void onComplete() { + if (completed) return; + completed = true; + + try { + _currentOverlayEntry?.remove(); + _currentOverlayEntry = null; + _isPlaying = false; + _safeScheduleNext(); + } catch (e) { + debugPrint('清理悬浮消息出错: $e'); + _isPlaying = false; + _safeScheduleNext(); + } + } + + switch (message.type) { + case 0: + return FloatingLuckGiftScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); + case 1: + //房间礼物 + return FloatingGiftScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); + case 2: + //游戏中奖 + return FloatingGameScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); + case 3: + //火箭 + return FloatingRoomRocketScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); + case 4: + //红包 + return FloatingRoomRedenvelopeScreenWidget( + message: message, + onAnimationCompleted: onComplete, + ); + default: + onComplete(); + return Container(); + } + } + + void activate() { + _isDisposed = false; + } + + void dispose() { + _isDisposed = true; + _currentOverlayEntry?.remove(); + _currentOverlayEntry = null; + _messageQueue.clear(); + _isPlaying = false; + _isProcessing = false; + } + + void removeRoom() { + // 正确地从 PriorityQueue 中移除特定类型的消息 + _removeMessagesByType(1); + } + + // 辅助方法:移除特定类型的消息 + void _removeMessagesByType(int type) { + // 由于 PriorityQueue 没有直接的方法来移除特定元素, + // 我们需要重建队列,排除指定类型的消息 + final newQueue = SCPriorityQueue( + (a, b) => b.priority.compareTo(a.priority), + ); + + // 遍历原始队列,只添加非指定类型的消息 + while (_messageQueue.isNotEmpty) { + final message = _messageQueue.removeFirst(); + if (message.type != type) { + newQueue.add(message); + } + } + + // 将新队列赋值给原队列 + while (newQueue.isNotEmpty) { + _messageQueue.add(newQueue.removeFirst()); + } + } + + // 辅助方法:获取队列信息 + int get queueLength => _messageQueue.length; + + bool get isPlaying => _isPlaying; +} diff --git a/lib/shared/data_sources/sources/remote/net/api.dart b/lib/shared/data_sources/sources/remote/net/api.dart index cee2079..2756327 100644 --- a/lib/shared/data_sources/sources/remote/net/api.dart +++ b/lib/shared/data_sources/sources/remote/net/api.dart @@ -130,6 +130,7 @@ class BaseNetworkClient { String path, { Map? queryParams, Map? extra, + bool allowNullBody = false, required T Function(dynamic) fromJson, CancelToken? cancelToken, ProgressCallback? onSendProgress, @@ -140,6 +141,7 @@ class BaseNetworkClient { method: 'GET', queryParams: queryParams, extra: extra, + allowNullBody: allowNullBody, fromJson: fromJson, cancelToken: cancelToken, onSendProgress: onSendProgress, @@ -153,6 +155,7 @@ class BaseNetworkClient { dynamic data, Map? queryParams, Map? extra, + bool allowNullBody = false, required T Function(dynamic) fromJson, CancelToken? cancelToken, ProgressCallback? onSendProgress, @@ -164,6 +167,7 @@ class BaseNetworkClient { data: data, queryParams: queryParams, extra: extra, + allowNullBody: allowNullBody, fromJson: fromJson, cancelToken: cancelToken, onSendProgress: onSendProgress, @@ -177,6 +181,7 @@ class BaseNetworkClient { dynamic data, Map? queryParams, Map? extra, + bool allowNullBody = false, required T Function(dynamic) fromJson, CancelToken? cancelToken, ProgressCallback? onSendProgress, @@ -188,6 +193,7 @@ class BaseNetworkClient { data: data, queryParams: queryParams, extra: extra, + allowNullBody: allowNullBody, fromJson: fromJson, cancelToken: cancelToken, onSendProgress: onSendProgress, @@ -201,6 +207,7 @@ class BaseNetworkClient { dynamic data, Map? queryParams, Map? extra, + bool allowNullBody = false, required T Function(dynamic) fromJson, CancelToken? cancelToken, ProgressCallback? onSendProgress, @@ -212,6 +219,7 @@ class BaseNetworkClient { data: data, queryParams: queryParams, extra: extra, + allowNullBody: allowNullBody, fromJson: fromJson, cancelToken: cancelToken, onSendProgress: onSendProgress, @@ -226,6 +234,7 @@ class BaseNetworkClient { dynamic data, Map? queryParams, Map? extra, + bool allowNullBody = false, CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, @@ -251,6 +260,8 @@ class BaseNetworkClient { if (baseResponse.success) { if (baseResponse.body != null) { return baseResponse.body!; + } else if (allowNullBody && null is T) { + return null as T; } else { if (T == bool) return false as T; if (T == int) return 0 as T; diff --git a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart index 7e48c46..f64bc4f 100644 --- a/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart +++ b/lib/shared/data_sources/sources/repositories/sc_room_repository_imp.dart @@ -213,6 +213,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { final result = await http.post( "11335ecd55d99c9100f5cd070e5b73f1574aff257ce7e668d08f4caccd1c6232", data: {"roomId": roomId, "mickIndex": mickIndex}, + allowNullBody: true, fromJson: (json) => null, ); return result; @@ -224,6 +225,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { final result = await http.post( "70ffb9681e0103463b1faf883935e55d", data: {"roomId": roomId, "mickIndex": mickIndex, "lock": lock}, + allowNullBody: true, fromJson: (json) => null, ); return result; @@ -405,6 +407,7 @@ class SCChatRoomRepository implements SocialChatRoomRepository { final result = await http.post( "68900c787a89c1092b21ee1a610ef323380d4f62784dacdb4e3d4e1f60770a39", data: {"roomId": roomId, "userId": userId}, + allowNullBody: true, fromJson: (json) => null, ); return result; diff --git a/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart b/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart index 5b26365..ad6b346 100644 --- a/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart +++ b/lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart @@ -348,6 +348,7 @@ class SCAccountRepository implements SocialChatUserRepository { final result = await http.post( "745ce65f1d68f106702ad3c636aca0e0ac57f82127b3ac414551924946593db7", data: {"pwd": pwd}, + allowNullBody: true, fromJson: (json) => null, ); return result; diff --git a/lib/shared/tools/sc_gift_vap_svga_manager.dart b/lib/shared/tools/sc_gift_vap_svga_manager.dart index 340487f..eda1c7d 100644 --- a/lib/shared/tools/sc_gift_vap_svga_manager.dart +++ b/lib/shared/tools/sc_gift_vap_svga_manager.dart @@ -6,6 +6,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_svga/flutter_svga.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; +import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart'; import 'package:tancent_vap/utils/constant.dart'; import 'package:tancent_vap/widgets/vap_view.dart'; @@ -267,6 +268,9 @@ class SCGiftVapSvgaManager { if (_dis) { return; } + SCRoomEffectScheduler().completeHighCostTask( + debugLabel: _currentTask?.path, + ); _play = false; _currentTask = null; _scheduleNextTask(delay: delay); @@ -316,6 +320,9 @@ class SCGiftVapSvgaManager { if (status.isCompleted) { _log('svga completed path=${_currentTask?.path}'); _rsc?.reset(); + SCRoomEffectScheduler().completeHighCostTask( + debugLabel: _currentTask?.path, + ); _play = false; _currentTask = null; _pn(); @@ -363,8 +370,13 @@ class SCGiftVapSvgaManager { return; } _tq.add(task); + SCRoomEffectScheduler().registerHighCostTaskQueued(debugLabel: path); + unawaited(preload(path)); while (_tq.length > _maxPendingTaskCount) { final removedTask = _tq.removeLast(); + SCRoomEffectScheduler().completeHighCostTask( + debugLabel: removedTask.path, + ); _log( 'trim queued task path=${removedTask.path} ' 'priority=${removedTask.priority} queue=${_tq.length}', @@ -547,6 +559,7 @@ class SCGiftVapSvgaManager { _rsc?.stop(); _rsc?.reset(); _rsc?.videoItem = null; + SCRoomEffectScheduler().clearHighCostTasks(reason: 'stop_playback'); } // 释放资源 diff --git a/lib/shared/tools/sc_network_image_utils.dart b/lib/shared/tools/sc_network_image_utils.dart index 80606c2..b841d7a 100644 --- a/lib/shared/tools/sc_network_image_utils.dart +++ b/lib/shared/tools/sc_network_image_utils.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/painting.dart'; @@ -37,26 +38,56 @@ Map? buildNetworkImageHeaders(String url) { return headers; } -ImageProvider buildCachedImageProvider(String url) { +int? _resolveImageProviderCacheDimension(double? logicalSize) { + if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) { + return null; + } + final devicePixelRatio = + PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 2.0; + final maxDimension = SCGlobalConfig.isLowPerformanceDevice ? 1280 : 1920; + final pixelSize = (logicalSize * devicePixelRatio).round(); + return pixelSize.clamp(1, maxDimension).toInt(); +} + +ImageProvider buildCachedImageProvider( + String url, { + double? logicalWidth, + double? logicalHeight, +}) { + late final ImageProvider provider; if (url.startsWith("http")) { - return ExtendedNetworkImageProvider( + provider = ExtendedNetworkImageProvider( url, headers: buildNetworkImageHeaders(url), cache: true, cacheMaxAge: const Duration(days: 7), ); + } else if (url.startsWith('/')) { + provider = FileImage(File(url)); + } else { + provider = AssetImage(url); } - return FileImage(File(url)); + return ResizeImage.resizeIfNeeded( + _resolveImageProviderCacheDimension(logicalWidth), + _resolveImageProviderCacheDimension(logicalHeight), + provider, + ); } -Future warmNetworkImage( - String url, { +Future warmImageResource( + String resource, { + double? logicalWidth, + double? logicalHeight, Duration timeout = const Duration(seconds: 6), }) async { - if (url.isEmpty) { + if (resource.isEmpty) { return false; } - final provider = buildCachedImageProvider(url); + final provider = buildCachedImageProvider( + resource, + logicalWidth: logicalWidth, + logicalHeight: logicalHeight, + ); final stream = provider.resolve(ImageConfiguration.empty); final completer = Completer(); late final ImageStreamListener listener; @@ -83,3 +114,17 @@ Future warmNetworkImage( }, ); } + +Future warmNetworkImage( + String url, { + double? logicalWidth, + double? logicalHeight, + Duration timeout = const Duration(seconds: 6), +}) { + return warmImageResource( + url, + logicalWidth: logicalWidth, + logicalHeight: logicalHeight, + timeout: timeout, + ); +} diff --git a/lib/shared/tools/sc_room_effect_scheduler.dart b/lib/shared/tools/sc_room_effect_scheduler.dart new file mode 100644 index 0000000..a25fc6e --- /dev/null +++ b/lib/shared/tools/sc_room_effect_scheduler.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +class SCRoomEffectScheduler { + static const Duration _deferredDrainInterval = Duration(milliseconds: 60); + + static final SCRoomEffectScheduler _instance = + SCRoomEffectScheduler._internal(); + + factory SCRoomEffectScheduler() => _instance; + + SCRoomEffectScheduler._internal(); + + final Queue<_DeferredRoomEffectTask> _deferredTasks = + Queue<_DeferredRoomEffectTask>(); + + Timer? _deferredDrainTimer; + int _pendingHighCostTaskCount = 0; + int _nextTaskId = 0; + + bool get hasActiveHighCostEffects => _pendingHighCostTaskCount > 0; + + void registerHighCostTaskQueued({String? debugLabel}) { + _pendingHighCostTaskCount += 1; + _cancelDeferredDrain(); + _log( + 'register_high_cost task=${debugLabel ?? "unknown"} ' + 'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}', + ); + } + + void completeHighCostTask({String? debugLabel}) { + if (_pendingHighCostTaskCount > 0) { + _pendingHighCostTaskCount -= 1; + } + _log( + 'complete_high_cost task=${debugLabel ?? "unknown"} ' + 'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}', + ); + _scheduleDeferredDrainIfNeeded(); + } + + void clearHighCostTasks({String reason = 'unknown'}) { + if (_pendingHighCostTaskCount == 0) { + _scheduleDeferredDrainIfNeeded(); + return; + } + _pendingHighCostTaskCount = 0; + _log( + 'clear_high_cost reason=$reason ' + 'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}', + ); + _scheduleDeferredDrainIfNeeded(); + } + + void scheduleDeferredEffect({ + required String debugLabel, + required VoidCallback action, + }) { + if (!hasActiveHighCostEffects) { + action(); + return; + } + + final task = _DeferredRoomEffectTask( + id: _nextTaskId++, + debugLabel: debugLabel, + action: action, + ); + _deferredTasks.add(task); + _log( + 'defer task=${task.debugLabel} id=${task.id} ' + 'pending=$_pendingHighCostTaskCount deferred=${_deferredTasks.length}', + ); + } + + void clearDeferredTasks({String reason = 'unknown'}) { + if (_deferredTasks.isEmpty) { + _cancelDeferredDrain(); + return; + } + _deferredTasks.clear(); + _cancelDeferredDrain(); + _log('clear_deferred reason=$reason'); + } + + void _scheduleDeferredDrainIfNeeded() { + if (hasActiveHighCostEffects || + _deferredTasks.isEmpty || + _deferredDrainTimer != null) { + return; + } + _deferredDrainTimer = Timer(_deferredDrainInterval, _drainNextDeferredTask); + } + + void _drainNextDeferredTask() { + _deferredDrainTimer = null; + if (hasActiveHighCostEffects || _deferredTasks.isEmpty) { + return; + } + + final task = _deferredTasks.removeFirst(); + _log( + 'drain_deferred task=${task.debugLabel} id=${task.id} ' + 'remaining=${_deferredTasks.length}', + ); + try { + task.action(); + } catch (error, stackTrace) { + debugPrint( + '[RoomEffectScheduler] deferred task failed: $error\n$stackTrace', + ); + } + + _scheduleDeferredDrainIfNeeded(); + } + + void _cancelDeferredDrain() { + _deferredDrainTimer?.cancel(); + _deferredDrainTimer = null; + } + + void _log(String message) { + debugPrint('[RoomEffectScheduler] $message'); + } +} + +class _DeferredRoomEffectTask { + const _DeferredRoomEffectTask({ + required this.id, + required this.debugLabel, + required this.action, + }); + + final int id; + final String debugLabel; + final VoidCallback action; +} diff --git a/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart b/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart index 87609c6..51a0dcd 100644 --- a/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart +++ b/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/app_localizations.dart'; @@ -19,62 +21,145 @@ class LGiftAnimalPage extends StatefulWidget { class _GiftAnimalPageState extends State with TickerProviderStateMixin { - static const String _luckyGiftRewardFrameAssetPath = - "sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga"; late final GiftAnimationManager _giftAnimationManager; @override Widget build(BuildContext context) { return SizedBox( height: 332.w, - child: Consumer( - builder: (context, ref, child) { - return Stack( - clipBehavior: Clip.none, - children: List.generate( - 4, - (index) => _buildGiftTickerItem(context, ref, index), - ), - ); - }, + child: Stack( + clipBehavior: Clip.none, + children: List.generate(4, (index) => _GiftTickerSlot(index: index)), ), ); } - Widget _buildGiftTickerItem( - BuildContext context, - GiftAnimationManager ref, - int index, - ) { - final gift = ref.giftMap[index]; - if (gift == null || ref.animationControllerList.length <= index) { - return const SizedBox.shrink(); + @override + void dispose() { + _giftAnimationManager.cleanupAnimationResources(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _giftAnimationManager = Provider.of( + context, + listen: false, + ); + initAnimal(); + } + + void initAnimal() { + List beans = []; + double top = 60; + for (int i = 0; i < 4; i++) { + var bean = LGiftScrollingScreenAnimsBean(); + var controller = AnimationController( + value: 0, + duration: const Duration(milliseconds: 360), + vsync: this, + ); + bean.controller = controller; + bean.countPulseController = AnimationController( + value: 1, + duration: const Duration(milliseconds: 280), + vsync: this, + ); + bean.luckyGiftPinnedMargin = EdgeInsets.only(top: top); + bean.verticalAnimation = EdgeInsetsTween( + begin: EdgeInsets.only(top: top), + end: EdgeInsets.only(top: 0), + ).animate( + CurvedAnimation(parent: controller, curve: Curves.easeOutCubic), + ); + bean.sizeAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 1, + end: 1.18, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + weight: 65, + ), + TweenSequenceItem( + tween: Tween( + begin: 1.18, + end: 1, + ).chain(CurveTween(curve: Curves.easeInCubic)), + weight: 35, + ), + ]).animate( + CurvedAnimation( + parent: bean.countPulseController, + curve: Curves.linear, + ), + ); + beans.add(bean); + top = top + 70; } - final bean = ref.animationControllerList[index]; - return AnimatedBuilder( - animation: bean.controller, - builder: (context, child) { - final showLuckyRewardFrame = gift.showLuckyRewardFrame; - final tickerMargin = - showLuckyRewardFrame - ? bean.luckyGiftPinnedMargin - : bean.verticalAnimation.value; - return Container( - margin: tickerMargin, - width: - ScreenUtil().screenWidth * (showLuckyRewardFrame ? 0.96 : 0.78), - child: _buildGiftTickerCard(context, gift, bean.sizeAnimation.value), + _giftAnimationManager.attachAnimationControllers(beans); + } + + String getBg(String type) { + return "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png"; + } +} + +class _GiftTickerSlot extends StatelessWidget { + const _GiftTickerSlot({required this.index}); + + final int index; + + @override + Widget build(BuildContext context) { + return Selector( + selector: (context, manager) => manager.slotSnapshotAt(index), + builder: (context, snapshot, child) { + if (snapshot == null) { + return const SizedBox.shrink(); + } + return RepaintBoundary( + child: AnimatedBuilder( + animation: Listenable.merge([ + snapshot.bean.controller, + snapshot.bean.countPulseController, + ]), + builder: (context, child) { + final showLuckyRewardFrame = snapshot.showLuckyRewardFrame; + final tickerMargin = + showLuckyRewardFrame + ? snapshot.bean.luckyGiftPinnedMargin + : snapshot.bean.verticalAnimation.value; + return Container( + margin: tickerMargin, + width: + ScreenUtil().screenWidth * + (showLuckyRewardFrame ? 0.96 : 0.78), + child: _GiftTickerCard( + snapshot: snapshot, + animatedSize: 22.sp * snapshot.bean.sizeAnimation.value, + ), + ); + }, + ), ); }, ); } +} - Widget _buildGiftTickerCard( - BuildContext context, - LGiftModel gift, - double animatedSize, - ) { - final showLuckyRewardFrame = gift.showLuckyRewardFrame; +class _GiftTickerCard extends StatelessWidget { + const _GiftTickerCard({required this.snapshot, required this.animatedSize}); + + static const String _luckyGiftRewardFrameAssetPath = + "sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga"; + + final GiftAnimationSlotSnapshot snapshot; + final double animatedSize; + + @override + Widget build(BuildContext context) { + final showLuckyRewardFrame = snapshot.showLuckyRewardFrame; return SizedBox( height: 52.w, child: Stack( @@ -91,16 +176,18 @@ class _GiftAnimalPageState extends State top: 3.w, bottom: 3.w, ), - decoration: BoxDecoration( + decoration: const BoxDecoration( image: DecorationImage( - image: AssetImage(getBg("")), + image: AssetImage( + "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png", + ), fit: BoxFit.fill, ), ), child: Row( children: [ netImage( - url: gift.sendUserPic, + url: snapshot.sendUserPic, shape: BoxShape.circle, width: 26.w, height: 26.w, @@ -114,11 +201,11 @@ class _GiftAnimalPageState extends State children: [ socialchatNickNameText( maxWidth: 88.w, - gift.sendUserName, + snapshot.sendUserName, fontSize: 12.sp, fontWeight: FontWeight.w500, type: "", - needScroll: gift.sendUserName.characters.length > 8, + needScroll: snapshot.sendUserName.characters.length > 8, ), SizedBox(height: 1.w), Row( @@ -133,7 +220,7 @@ class _GiftAnimalPageState extends State ), Flexible( child: Text( - gift.sendToUserName, + snapshot.sendToUserName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -165,25 +252,25 @@ class _GiftAnimalPageState extends State crossAxisAlignment: CrossAxisAlignment.center, children: [ netImage( - url: gift.giftPic, + url: snapshot.giftPic, fit: BoxFit.cover, borderRadius: BorderRadius.circular(4.w), width: 34.w, height: 34.w, ), - if (gift.giftCount > 0) ...[ + if (snapshot.giftCount > 0) ...[ SizedBox(width: 8.w), Flexible( child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, - child: _buildGiftCountLabel(gift, animatedSize), + child: _buildGiftCountLabel(snapshot.giftCount), ), ), ], if (showLuckyRewardFrame) ...[ SizedBox(width: 6.w), - _buildLuckyGiftRewardFrame(gift), + _buildLuckyGiftRewardFrame(), ], ], ), @@ -194,9 +281,7 @@ class _GiftAnimalPageState extends State ); } - Widget _buildGiftCountLabel(LGiftModel gift, double animatedSize) { - final xFontSize = animatedSize; - final countFontSize = animatedSize; + Widget _buildGiftCountLabel(num giftCount) { return Directionality( textDirection: TextDirection.ltr, child: Row( @@ -206,7 +291,7 @@ class _GiftAnimalPageState extends State Text( "x", style: TextStyle( - fontSize: xFontSize, + fontSize: animatedSize, fontStyle: FontStyle.italic, color: const Color(0xFFFFD400), fontWeight: FontWeight.bold, @@ -214,10 +299,11 @@ class _GiftAnimalPageState extends State ), ), SizedBox(width: 2.w), - Text( - _giftCountText(gift.giftCount), + _AnimatedGiftCountText( + targetCount: giftCount, + stepUnit: snapshot.giftCountStepUnit, style: TextStyle( - fontSize: countFontSize, + fontSize: animatedSize, fontStyle: FontStyle.italic, color: const Color(0xFFFFD400), fontWeight: FontWeight.bold, @@ -229,21 +315,23 @@ class _GiftAnimalPageState extends State ); } - Widget _buildLuckyGiftRewardFrame(LGiftModel gift) { + Widget _buildLuckyGiftRewardFrame() { return SizedBox( width: 108.w, height: 52.w, child: Stack( alignment: Alignment.center, children: [ - SCSvgaAssetWidget( - assetPath: _luckyGiftRewardFrameAssetPath, - width: 108.w, - height: 52.w, - fit: BoxFit.cover, - loop: true, - allowDrawingOverflow: true, - fallback: _buildLuckyGiftRewardFrameFallback(), + RepaintBoundary( + child: SCSvgaAssetWidget( + assetPath: _luckyGiftRewardFrameAssetPath, + width: 108.w, + height: 52.w, + fit: BoxFit.cover, + loop: true, + allowDrawingOverflow: true, + fallback: const _LuckyGiftRewardFrameFallback(), + ), ), Padding( padding: EdgeInsetsDirectional.only( @@ -254,7 +342,7 @@ class _GiftAnimalPageState extends State child: FittedBox( fit: BoxFit.scaleDown, child: Text( - "+${_giftRewardAmountText(gift)}", + "+${_giftRewardAmountText()}", maxLines: 1, style: TextStyle( fontSize: 16.sp, @@ -278,7 +366,26 @@ class _GiftAnimalPageState extends State ); } - Widget _buildLuckyGiftRewardFrameFallback() { + String _giftRewardAmountText() { + final rewardAmount = snapshot.rewardAmount; + if (rewardAmount > 0) { + if (rewardAmount > 9999) { + return "${(rewardAmount / 1000).toStringAsFixed(0)}k"; + } + if (rewardAmount % 1 == 0) { + return rewardAmount.toInt().toString(); + } + return rewardAmount.toString(); + } + return snapshot.rewardAmountText; + } +} + +class _LuckyGiftRewardFrameFallback extends StatelessWidget { + const _LuckyGiftRewardFrameFallback(); + + @override + Widget build(BuildContext context) { return Container( width: 108.w, height: 52.w, @@ -300,118 +407,154 @@ class _GiftAnimalPageState extends State ), ); } +} - String _giftCountText(num count) { - return count % 1 == 0 ? count.toInt().toString() : count.toString(); - } +class _AnimatedGiftCountText extends StatefulWidget { + const _AnimatedGiftCountText({ + required this.targetCount, + required this.stepUnit, + required this.style, + }); - String _giftRewardAmountText(LGiftModel gift) { - final rewardAmount = gift.rewardAmount; - if (rewardAmount > 0) { - if (rewardAmount > 9999) { - return "${(rewardAmount / 1000).toStringAsFixed(0)}k"; - } - if (rewardAmount % 1 == 0) { - return rewardAmount.toInt().toString(); - } - return rewardAmount.toString(); - } - return gift.rewardAmountText; - } + final num targetCount; + final num stepUnit; + final TextStyle style; @override - void dispose() { - _giftAnimationManager.cleanupAnimationResources(); - super.dispose(); - } + State<_AnimatedGiftCountText> createState() => _AnimatedGiftCountTextState(); +} + +class _AnimatedGiftCountTextState extends State<_AnimatedGiftCountText> { + Timer? _stepTimer; + late double _displayValue; @override void initState() { super.initState(); - _giftAnimationManager = Provider.of( - context, - listen: false, - ); - initAnimal(); + _displayValue = _resolveInitialDisplayValue(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _startSteppedAnimation(widget.targetCount.toDouble()); + }); } - void initAnimal() { - List beans = []; - double top = 60; - for (int i = 0; i < 4; i++) { - var bean = LGiftScrollingScreenAnimsBean(); - var controller = AnimationController( - value: 0, - duration: const Duration(milliseconds: 5000), - vsync: this, - ); - bean.controller = controller; - bean.luckyGiftPinnedMargin = EdgeInsets.only(top: top); - // bean.transverseAnimation = Tween( - // begin: Offset(ScreenUtil().screenWidth, 0), - // end: Offset(0, 0), - // ).animate( - // CurvedAnimation( - // parent: controller, - // curve: Interval(0.0, 0.45, curve: Curves.ease), - // ), - // ); - bean.verticalAnimation = EdgeInsetsTween( - begin: EdgeInsets.only(top: top), - end: EdgeInsets.only(top: 0), - ).animate( - CurvedAnimation( - parent: controller, - curve: Interval(0.2, 1, curve: Curves.easeOut), - ), - ); - bean.sizeAnimation = Tween(begin: 0, end: 22).animate( - CurvedAnimation( - parent: controller, - curve: Interval(0.45, 0.55, curve: Curves.ease), - ), - ); - beans.add(bean); - top = top + 70; + @override + void didUpdateWidget(covariant _AnimatedGiftCountText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.targetCount == widget.targetCount && + oldWidget.stepUnit == widget.stepUnit) { + return; } - beans[0].controller.addStatusListener((state) { - if (state == AnimationStatus.completed) { - //动画完成监听 - _giftAnimationManager.markAnimationAsFinished(0); - } - }); - beans[1].controller.addStatusListener((state) { - if (state == AnimationStatus.completed) { - //动画完成监听 - _giftAnimationManager.markAnimationAsFinished(1); - } - }); - beans[2].controller.addStatusListener((state) { - if (state == AnimationStatus.completed) { - //动画完成监听 - _giftAnimationManager.markAnimationAsFinished(2); - } - }); - beans[3].controller.addStatusListener((state) { - if (state == AnimationStatus.completed) { - //动画完成监听 - _giftAnimationManager.markAnimationAsFinished(3); - } - }); - _giftAnimationManager.attachAnimationControllers(beans); + _startSteppedAnimation(widget.targetCount.toDouble()); } - String getBg(String type) { - return "sc_images/room/sc_icon_room_gift_left_no_vip_bg.png"; + @override + void dispose() { + _stepTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text(_formatGiftCount(_resolvedDisplayCount()), style: widget.style); + } + + double _resolveInitialDisplayValue() { + final targetValue = widget.targetCount.toDouble(); + final stepUnit = _resolveStepUnit(targetValue.abs()); + if (targetValue <= 0 || stepUnit <= 0) { + return targetValue; + } + return targetValue <= stepUnit ? targetValue : stepUnit; + } + + void _startSteppedAnimation(double nextValue) { + _stepTimer?.cancel(); + final delta = nextValue - _displayValue; + if (delta.abs() < 0.0001) { + return; + } + if (delta < 0) { + if (!mounted) { + return; + } + setState(() { + _displayValue = nextValue; + }); + return; + } + + final stepUnit = _resolveStepUnit(delta.abs()); + if (stepUnit <= 0) { + if (!mounted) { + return; + } + setState(() { + _displayValue = nextValue; + }); + return; + } + + var tickCount = (delta.abs() / stepUnit).ceil(); + if (tickCount <= 1) { + if (!mounted) { + return; + } + setState(() { + _displayValue = nextValue; + }); + return; + } + tickCount = tickCount.clamp(2, 40); + final increment = delta / tickCount; + final intervalMs = ((700 / tickCount).round()).clamp(28, 80); + var completedTicks = 0; + _stepTimer = Timer.periodic(Duration(milliseconds: intervalMs), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + completedTicks += 1; + final reachedEnd = completedTicks >= tickCount; + setState(() { + _displayValue = reachedEnd ? nextValue : (_displayValue + increment); + }); + if (reachedEnd) { + timer.cancel(); + _stepTimer = null; + } + }); + } + + double _resolveStepUnit(double delta) { + final candidate = widget.stepUnit.abs().toDouble(); + if (candidate <= 0) { + return delta; + } + return candidate.clamp(0.0001, delta); + } + + num _resolvedDisplayCount() { + if (widget.targetCount % 1 == 0) { + return _displayValue.round(); + } + return double.parse(_displayValue.toStringAsFixed(1)); } } +String _formatGiftCount(num count) { + return count % 1 == 0 ? count.toInt().toString() : count.toString(); +} + class LGiftScrollingScreenAnimsBean { - // late Animation transverseAnimation; late Animation verticalAnimation; late EdgeInsets luckyGiftPinnedMargin; late AnimationController controller; + late AnimationController countPulseController; late Animation sizeAnimation; + Timer? dismissTimer; } class LGiftModel { @@ -444,4 +587,7 @@ class LGiftModel { //id String labelId = ""; + + // 数量视觉步长。单次点击送 1 个时会按 1 递增;单次点击送 25 个时直接按 25 跳。 + num giftCountStepUnit = 1; } diff --git a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart index 8650bd2..50317da 100644 --- a/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart +++ b/lib/ui_kit/widgets/room/anim/room_entrance_screen.dart @@ -1,325 +1,351 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/ui_kit/components/sc_compontent.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/services/audio/rtm_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; - -class RoomAnimationQueueScreen extends StatefulWidget { - const RoomAnimationQueueScreen({super.key}); - - @override - State createState() => - _RoomAnimationQueueScreenState(); -} - -class _RoomAnimationQueueScreenState extends State { - static const int _maxAnimationQueueLength = 12; - - final List _animationQueue = []; - bool _isQueueProcessing = false; - final Map> _animationKeys = {}; - - @override - void initState() { - super.initState(); - Provider.of(context, listen: false).msgUserJoinListener = - _msgUserJoinListener; - } - - _msgUserJoinListener(Msg msg) { - Future.delayed(Duration(milliseconds: 550), () { - if (!_effectsEnabled) { - return; - } - _addToQueue(msg); - }); - } - - void _addToQueue(Msg msg) { - if (!_effectsEnabled || !mounted) { - return; - } - setState(() { - final taskId = DateTime.now().millisecondsSinceEpoch; - final animationKey = GlobalKey<_RoomEntranceAnimationState>(); - - _trimQueueOverflow(); - _animationKeys[taskId] = animationKey; - - final task = AnimationTask( - id: taskId, - msg: msg, - onComplete: () { - if (_animationQueue.isNotEmpty && - _animationQueue.first.id == taskId) { - setState(() { - _animationQueue.removeAt(0); - _animationKeys.remove(taskId); - }); - - if (_animationQueue.isNotEmpty) { - _startNextAnimation(); - } else { - setState(() => _isQueueProcessing = false); - } - } - }, - ); - - _animationQueue.add(task); - }); - - if (!_isQueueProcessing && _animationQueue.isNotEmpty) { - setState(() => _isQueueProcessing = true); - _startNextAnimation(); - } - } - - void _trimQueueOverflow() { - while (_animationQueue.length >= _maxAnimationQueueLength) { - final removeIndex = - _isQueueProcessing && _animationQueue.length > 1 ? 1 : 0; - final removedTask = _animationQueue.removeAt(removeIndex); - _animationKeys.remove(removedTask.id); - } - } - - void _startNextAnimation({int retryCount = 0}) { - if (_animationQueue.isEmpty) return; - - SchedulerBinding.instance.addPostFrameCallback((_) { - if (_animationQueue.isNotEmpty) { - final task = _animationQueue.first; - final key = _animationKeys[task.id]; - - if (key?.currentState != null) { - key!.currentState!._startAnimation(); - } else if (retryCount < 3) { - // 有限次重试,避免无限循环 - Future.delayed(const Duration(milliseconds: 50), () { - _startNextAnimation(retryCount: retryCount + 1); - }); - } else { - // 重试多次失败后,跳过当前动画 - debugPrint("动画启动失败,跳过当前任务"); - task.onComplete(); - } - } - }); - } - - void _clearQueue() { - if (!mounted) { - _animationQueue.clear(); - _animationKeys.clear(); - _isQueueProcessing = false; - return; - } - setState(() { - _animationQueue.clear(); - _animationKeys.clear(); - _isQueueProcessing = false; - }); - } - - bool get _effectsEnabled => - Provider.of( - context, - listen: false, - ).shouldShowRoomVisualEffects; - - @override - void dispose() { - final rtmProvider = Provider.of(context, listen: false); - if (rtmProvider.msgUserJoinListener == _msgUserJoinListener) { - rtmProvider.msgUserJoinListener = null; - } - _animationQueue.clear(); - _animationKeys.clear(); - _isQueueProcessing = false; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final effectsEnabled = context.select( - (rtcProvider) => rtcProvider.shouldShowRoomVisualEffects, - ); - if (!effectsEnabled) { - if (_animationQueue.isNotEmpty || _isQueueProcessing) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _clearQueue(); - } - }); - } - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - if (_animationQueue.isNotEmpty) - RoomEntranceAnimation( - key: _animationKeys[_animationQueue.first.id], - msg: _animationQueue.first.msg, - onComplete: _animationQueue.first.onComplete, - ), - ], - ), - ], - ), - ); - } -} - -class AnimationTask { - final int id; - final Msg msg; - final VoidCallback onComplete; - - AnimationTask({ - required this.id, - required this.msg, - required this.onComplete, - }); -} - -class RoomEntranceAnimation extends StatefulWidget { - final VoidCallback onComplete; - final Msg msg; - - const RoomEntranceAnimation({ - super.key, - required this.onComplete, - required this.msg, - }); - - @override - State createState() => _RoomEntranceAnimationState(); -} - -class _RoomEntranceAnimationState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _offsetAnimation; - bool _isAnimating = false; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(seconds: 4), - vsync: this, - ); - - _offsetAnimation = TweenSequence([ - TweenSequenceItem( - tween: Tween( - begin: const Offset(1.0, 0.0), - end: const Offset(0.0, 0.0), - ).chain(CurveTween(curve: Curves.easeOut)), - weight: 40.0, - ), - TweenSequenceItem( - tween: ConstantTween(const Offset(0.0, 0.0)), - weight: 20.0, - ), - TweenSequenceItem( - tween: Tween( - begin: const Offset(0.0, 0.0), - end: const Offset(-1.0, 0.0), - ).chain(CurveTween(curve: Curves.easeIn)), - weight: 40.0, - ), - ]).animate(_controller); - - _controller.addStatusListener((status) { - if (status == AnimationStatus.completed) { - setState(() => _isAnimating = false); - widget.onComplete(); - } - }); - } - - void _startAnimation() { - if (_isAnimating) return; - - setState(() => _isAnimating = true); - _controller.reset(); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SlideTransition( - position: _offsetAnimation, - child: Container( - width: ScreenUtil().screenWidth * 0.8, - height: 37.w, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(getEntranceBg("")), - fit: BoxFit.fill, - ), - ), - child: Row( - textDirection: TextDirection.ltr, - children: [ - Padding( - padding: EdgeInsets.all(2.w), - child: netImage( - url: widget.msg.user?.userAvatar ?? "", - width: 28.w, - height: 28.w, - shape: BoxShape.circle, - ), - ), - SizedBox(width: 5.w), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 2.w), - socialchatNickNameText( - maxWidth: 150.w, - widget.msg.user?.userNickname ?? "", - fontSize: 12.sp, - type: "", - needScroll: - (widget.msg.user?.userNickname?.characters.length ?? 0) > - 15, - ), - text( - SCAppLocalizations.of(context)!.enterTheRoom, - fontSize: 12.sp, - lineHeight: 0.9, - textColor: Colors.white, - ), - ], - ), - ], - ), - ), - ); - } - - String getEntranceBg(String type) { - return "sc_images/room/entrance/sc_icon_room_entrance_no_vip_bg.png"; - } -} +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/ui_kit/components/sc_compontent.dart'; +import 'package:yumi/ui_kit/components/text/sc_text.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; +import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart'; +import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; + +class RoomAnimationQueueScreen extends StatefulWidget { + const RoomAnimationQueueScreen({super.key}); + + @override + State createState() => + _RoomAnimationQueueScreenState(); +} + +class _RoomAnimationQueueScreenState extends State { + static const int _maxAnimationQueueLength = 12; + + final List _animationQueue = []; + bool _isQueueProcessing = false; + final Map> _animationKeys = {}; + + @override + void initState() { + super.initState(); + Provider.of(context, listen: false).msgUserJoinListener = + _msgUserJoinListener; + } + + _msgUserJoinListener(Msg msg) { + Future.delayed(Duration(milliseconds: 550), () { + if (!_effectsEnabled) { + return; + } + unawaited( + warmImageResource( + msg.user?.userAvatar ?? "", + logicalWidth: 28.w, + logicalHeight: 28.w, + ), + ); + SCRoomEffectScheduler().scheduleDeferredEffect( + debugLabel: 'room_entrance_join', + action: () { + if (!_effectsEnabled || !mounted) { + return; + } + _addToQueue(msg); + }, + ); + }); + } + + void _addToQueue(Msg msg) { + if (!_effectsEnabled || !mounted) { + return; + } + bool shouldStartProcessing = false; + setState(() { + final taskId = DateTime.now().millisecondsSinceEpoch; + final animationKey = GlobalKey<_RoomEntranceAnimationState>(); + + _trimQueueOverflow(); + _animationKeys[taskId] = animationKey; + + final task = AnimationTask( + id: taskId, + msg: msg, + onComplete: () { + if (_animationQueue.isNotEmpty && + _animationQueue.first.id == taskId) { + setState(() { + _animationQueue.removeAt(0); + _animationKeys.remove(taskId); + }); + + if (_animationQueue.isNotEmpty) { + _startNextAnimation(); + } else { + setState(() => _isQueueProcessing = false); + } + } + }, + ); + + _animationQueue.add(task); + if (!_isQueueProcessing && _animationQueue.isNotEmpty) { + _isQueueProcessing = true; + shouldStartProcessing = true; + } + }); + + if (shouldStartProcessing) { + _startNextAnimation(); + } + } + + void _trimQueueOverflow() { + while (_animationQueue.length >= _maxAnimationQueueLength) { + final removeIndex = + _isQueueProcessing && _animationQueue.length > 1 ? 1 : 0; + final removedTask = _animationQueue.removeAt(removeIndex); + _animationKeys.remove(removedTask.id); + } + } + + void _startNextAnimation({int retryCount = 0}) { + if (_animationQueue.isEmpty) return; + + SchedulerBinding.instance.addPostFrameCallback((_) { + if (_animationQueue.isNotEmpty) { + final task = _animationQueue.first; + final key = _animationKeys[task.id]; + + if (key?.currentState != null) { + key!.currentState!._startAnimation(); + } else if (retryCount < 3) { + // 有限次重试,避免无限循环 + Future.delayed(const Duration(milliseconds: 50), () { + _startNextAnimation(retryCount: retryCount + 1); + }); + } else { + // 重试多次失败后,跳过当前动画 + debugPrint("动画启动失败,跳过当前任务"); + task.onComplete(); + } + } + }); + } + + void _clearQueue() { + if (!mounted) { + _animationQueue.clear(); + _animationKeys.clear(); + _isQueueProcessing = false; + return; + } + setState(() { + _animationQueue.clear(); + _animationKeys.clear(); + _isQueueProcessing = false; + }); + } + + bool get _effectsEnabled => + Provider.of( + context, + listen: false, + ).shouldShowRoomVisualEffects; + + @override + void dispose() { + final rtmProvider = Provider.of(context, listen: false); + if (rtmProvider.msgUserJoinListener == _msgUserJoinListener) { + rtmProvider.msgUserJoinListener = null; + } + _animationQueue.clear(); + _animationKeys.clear(); + _isQueueProcessing = false; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final effectsEnabled = context.select( + (rtcProvider) => rtcProvider.shouldShowRoomVisualEffects, + ); + if (!effectsEnabled) { + if (_animationQueue.isNotEmpty || _isQueueProcessing) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _clearQueue(); + } + }); + } + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + if (_animationQueue.isNotEmpty) + RoomEntranceAnimation( + key: _animationKeys[_animationQueue.first.id], + msg: _animationQueue.first.msg, + onComplete: _animationQueue.first.onComplete, + ), + ], + ), + ], + ), + ); + } +} + +class AnimationTask { + final int id; + final Msg msg; + final VoidCallback onComplete; + + AnimationTask({ + required this.id, + required this.msg, + required this.onComplete, + }); +} + +class RoomEntranceAnimation extends StatefulWidget { + final VoidCallback onComplete; + final Msg msg; + + const RoomEntranceAnimation({ + super.key, + required this.onComplete, + required this.msg, + }); + + @override + State createState() => _RoomEntranceAnimationState(); +} + +class _RoomEntranceAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + bool _isAnimating = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 4), + vsync: this, + ); + + _offsetAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: const Offset(1.0, 0.0), + end: const Offset(0.0, 0.0), + ).chain(CurveTween(curve: Curves.easeOut)), + weight: 40.0, + ), + TweenSequenceItem( + tween: ConstantTween(const Offset(0.0, 0.0)), + weight: 20.0, + ), + TweenSequenceItem( + tween: Tween( + begin: const Offset(0.0, 0.0), + end: const Offset(-1.0, 0.0), + ).chain(CurveTween(curve: Curves.easeIn)), + weight: 40.0, + ), + ]).animate(_controller); + + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) { + setState(() => _isAnimating = false); + widget.onComplete(); + } + }); + } + + void _startAnimation() { + if (_isAnimating) return; + + setState(() => _isAnimating = true); + _controller.reset(); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: SlideTransition( + position: _offsetAnimation, + child: Container( + width: ScreenUtil().screenWidth * 0.8, + height: 37.w, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(getEntranceBg("")), + fit: BoxFit.fill, + ), + ), + child: Row( + textDirection: TextDirection.ltr, + children: [ + Padding( + padding: EdgeInsets.all(2.w), + child: netImage( + url: widget.msg.user?.userAvatar ?? "", + width: 28.w, + height: 28.w, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 5.w), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 2.w), + socialchatNickNameText( + maxWidth: 150.w, + widget.msg.user?.userNickname ?? "", + fontSize: 12.sp, + type: "", + needScroll: + (widget.msg.user?.userNickname?.characters.length ?? + 0) > + 15, + ), + text( + SCAppLocalizations.of(context)!.enterTheRoom, + fontSize: 12.sp, + lineHeight: 0.9, + textColor: Colors.white, + ), + ], + ), + ], + ), + ), + ), + ); + } + + String getEntranceBg(String type) { + return "sc_images/room/entrance/sc_icon_room_entrance_no_vip_bg.png"; + } +} diff --git a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart index 6fa462b..8d766b5 100644 --- a/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart +++ b/lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart @@ -1,5 +1,4 @@ import 'dart:collection'; -import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -212,6 +211,8 @@ class _RoomGiftSeatFlightOverlayState extends State static const int _maxQueuedRequests = 24; final Queue<_QueuedRoomGiftSeatFlightRequest> _queue = Queue(); + final Map> _imageProviderCache = + >{}; final GlobalKey _overlayKey = GlobalKey(); late final AnimationController _controller; @@ -287,6 +288,7 @@ class _RoomGiftSeatFlightOverlayState extends State _activeRequest = null; _activeImageProvider = null; _activeTargetOffset = null; + _imageProviderCache.clear(); _isPlaying = false; return; } @@ -296,6 +298,7 @@ class _RoomGiftSeatFlightOverlayState extends State _activeRequest = null; _activeImageProvider = null; _activeTargetOffset = null; + _imageProviderCache.clear(); _isPlaying = false; }); } @@ -495,17 +498,30 @@ class _RoomGiftSeatFlightOverlayState extends State } ImageProvider _resolveImageProvider(String imagePath) { - if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { - return buildCachedImageProvider(imagePath); + final currentRequest = _activeRequest ?? _centerRequest; + final logicalSize = + currentRequest == null + ? null + : currentRequest.beginSize > currentRequest.endSize + ? currentRequest.beginSize + : currentRequest.endSize; + final cacheKey = '$imagePath|${logicalSize?.toStringAsFixed(2) ?? "auto"}'; + final cachedProvider = _imageProviderCache[cacheKey]; + if (cachedProvider != null) { + return cachedProvider; } - if (imagePath.startsWith('/')) { - return FileImage(File(imagePath)); - } - return AssetImage(imagePath); + final provider = buildCachedImageProvider( + imagePath, + logicalWidth: logicalSize, + logicalHeight: logicalSize, + ); + _imageProviderCache[cacheKey] = provider; + return provider; } @override Widget build(BuildContext context) { + final overlaySize = MediaQuery.sizeOf(context); return IgnorePointer( child: SizedBox.expand( key: _overlayKey, @@ -518,11 +534,6 @@ class _RoomGiftSeatFlightOverlayState extends State : AnimatedBuilder( animation: _controller, builder: (context, child) { - final renderBox = - _overlayKey.currentContext?.findRenderObject() - as RenderBox?; - final overlaySize = - renderBox?.size ?? MediaQuery.sizeOf(context); final center = overlaySize.center(Offset.zero); final currentCenterRequest = _centerRequest ?? _activeRequest; @@ -576,7 +587,9 @@ class _RoomGiftSeatFlightOverlayState extends State ); } - return Stack(clipBehavior: Clip.none, children: children); + return RepaintBoundary( + child: Stack(clipBehavior: Clip.none, children: children), + ); }, ), ), diff --git a/lib/ui_kit/widgets/room/room_bottom_widget.dart b/lib/ui_kit/widgets/room/room_bottom_widget.dart index 48a83ef..008d831 100644 --- a/lib/ui_kit/widgets/room/room_bottom_widget.dart +++ b/lib/ui_kit/widgets/room/room_bottom_widget.dart @@ -277,9 +277,11 @@ class _RoomBottomWidgetState extends State { provider.isMic = !provider.isMic; provider.roomWheatMap.forEach((k, v) { - if (v.user?.id == - AccountStorage().getCurrentUser()?.userProfile?.id && - !provider.roomWheatMap[k]!.micMute!) { + 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( @@ -313,15 +315,7 @@ class _RoomBottomWidgetState extends State { } bool _shouldShowMic(RtcProvider provider) { - var show = false; - - provider.roomWheatMap.forEach((k, v) { - if (v.user?.id == AccountStorage().getCurrentUser()?.userProfile?.id) { - show = true; - } - }); - - return show; + return provider.isOnMai(); } } diff --git a/需求进度.md b/需求进度.md index a614092..5b76909 100644 --- a/需求进度.md +++ b/需求进度.md @@ -3,6 +3,21 @@ ## 当前总目标 - 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。 +## 本轮房间动效优化(进行中) +- 已按 2026-04-21 当前房间卡顿优化方案先落第一步“统一调度层”:新增房间特效调度器,在全屏 `VAP/SVGA` 高成本特效播放或排队期间,暂缓低优先级的房间进场动画、全局飘屏和座位飞屏入队,待高成本特效清空后按小间隔续播,先减少多条动画链路同一时刻抢主线程的情况。 +- 本步只处理“调度时机”,没有改动现有动画内部逻辑:礼物/飞屏/飘屏的现有倍率触发规则、现有上限截断、`customAnimationCount` 现有循环方式和当前视觉表现均保持原样,避免第一步就引入联动回归。 +- 已继续落第二步“局部重建/重绘隔离”:房间礼物播报条已从整层 `Consumer` 重建改成每个动画槽位独立 `Selector` 订阅,并把运行态数据拍平成快照,减少 `notifyListeners()` 时 4 个槽位整层一起重建;同时为礼物播报条、进场动画、座位飞屏和全局飘屏 overlay 补充 `RepaintBoundary`,先把高频位移动画和底层页面绘制隔离开,降低同屏重绘面积。 +- 第二步同样没有改动任何动画业务规则:礼物播报条合并策略、幸运礼物奖励展示、现有队列时长、`customAnimationCount` 的逐个入队方式、现有上限和触发条件都保持原样,这一步只收敛 rebuild / repaint 成本。 +- 已顺手补上高成本特效资源预热接线:当前全屏 `VAP/SVGA` 任务一入队就会触发已有的 preload 逻辑,让排队中的特效在前一个播放期间优先完成 `svga decode / 网络文件落缓存`,减少切到下一段时的首帧等待;这一步复用现有预热能力,不改变任何播放顺序和触发条件。 +- 已继续补上图片链路的“按展示尺寸解码 + 排队阶段预热”:`buildCachedImageProvider` 现已支持 `network/file/asset` 三类资源统一按逻辑尺寸生成 provider,并通过 `ResizeImage` 把解码尺寸压到实际展示大小;同时房间礼物播报条、进场动画、全局飘屏与座位飞屏在入队时会对头像/礼物图做异步预热,减少正式播到该条动画时首次解码和图片闪一下的概率。 +- 这一轮同样没有改动任何动画规则或队列行为:只补资源命中率和解码尺寸控制,不变更现有播放次数、上限、倍率阈值、`customAnimationCount` 逐个飞行方式和当前视觉逻辑。 +- 已继续收口座位飞屏的图片命中链路:当前在真正批量入队逐个飞行前,会先按飞行动画的大图尺寸预热一次礼物图;同时飞屏 overlay 内部会复用同路径同尺寸的 `ImageProvider`,减少 burst 场景里同一张礼物图连续多次构造 provider 和重复命中解析链路的开销。 +- 已继续专项收口“顶部礼物信息播报”的连击卡顿:当前已把礼物播报条的“整条入场动画”和“右侧 `xN` 数量脉冲/续命”拆成独立状态,连击到来时不再通过重启整条 5 秒控制器来硬续播,而是只刷新数量脉冲并延长当前播报条的空闲存活时间;同时右侧数量已改为从当前显示值平滑补间到目标值,减少高频连击或本地批量合并时出现的数字跳变、断档和忽大忽小。 +- 这一轮同样没有改礼物业务判断口径:未调整礼物连击的触发条件、上限、截断、倍率门槛和 `customAnimationCount` 等既有规则,只优化顶部播报条自身的显示状态机与数量刷新方式。 +- 已继续修正连击播报条“单点送 1 个却直接跳 `+5`”的问题:顶部礼物播报条现已接入 `customAnimationCount` 的点击粒度信息,数量动画不再只按合并后的总量做补间,而是按“单次点击对应的数量步长”逐步递进;例如连续点击 5 次、每次送 1 个时,会按 `+1 +1 +1 +1 +1` 的视觉节奏补齐,而不是直接把右侧数量跳成 `+5`。 +- 已继续修正连击档位特效偶发“不播”的问题:当前连击 `SVGA` 触发已改为只命中仓库里真实存在资源的档位集合,不再把 `20/88/200...` 这类当前工程里没有对应特效文件的档位当成可播放资源;同时命中判断会按“本次新增后跨过的最高有效资源档位”来触发,避免因为本地批量窗口把多次点击合成一包后,先命中到空资源路径而看起来像整段特效都没播。 +- 已按 2026-04-21 最新回归继续修正“飞向麦位的自制动画”缺失问题:座位飞屏现已从房间统一调度器的延后队列中移出,恢复为收到礼物消息后直接入队到全局 `RoomGiftSeatFlightOverlay`;保留既有的图片预热、同会话队列上限和 `customAnimationCount` 逐个飞行逻辑,只撤回此前对这一核心反馈动画的延迟调度,避免用户在礼物命中全屏特效或调度状态未及时清空时误以为飞屏动画消失。 + ## 本轮启动优化(非网络) - 已补回启动页正式展示逻辑:当前 `Weekly Star / Last Weekly CP` 两套自定义启动页都改为“本地有缓存才展示、无缓存不展示”;由于现阶段周榜与 CP 榜接口链路都还未 ready,缓存刷新逻辑已先关闭,所以当前启动阶段会直接回退到默认 splash,不再展示这两套定制视觉稿。相关恢复入口已在缓存类里用 `api-ready-launch-splash` 注释标记,后续接口 ready 后可直接搜索接回。 - 已将启动页展示时间收敛为 `3` 秒,并在右上角新增通用 `skip 倒计时` 按钮:当前按钮会按秒级动态展示剩余时间,点击可立即跳过;文案已补齐 `en/ar/tr/bn` 多语言翻译,并按 locale 输出倒计时文本,便于后续继续做 RTL 语言验收。 @@ -181,11 +196,18 @@ - `lib/shared/data_sources/models/enum/sc_gift_type.dart` - `lib/shared/tools/sc_network_image_utils.dart` - `lib/shared/tools/sc_gift_vap_svga_manager.dart` +- `lib/shared/tools/sc_room_effect_scheduler.dart` - `lib/services/general/sc_app_general_manager.dart` +- `lib/shared/data_sources/sources/local/floating_screen_manager.dart` - `lib/ui_kit/widgets/room/floating/floating_gift_screen_widget.dart` - `lib/ui_kit/widgets/room/floating/floating_luck_gift_screen_widget.dart` - `lib/services/room/rc_room_manager.dart` +- `lib/services/gift/gift_animation_manager.dart` - `lib/services/audio/rtc_manager.dart` +- `lib/modules/room/voice_room_page.dart` +- `lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart` +- `lib/ui_kit/widgets/room/anim/room_entrance_screen.dart` +- `lib/ui_kit/widgets/room/anim/room_gift_seat_flight_overlay.dart` - `lib/modules/room/edit/room_edit_page.dart` - `lib/modules/user/edit/edit_user_info_page2.dart` - `lib/shared/data_sources/sources/repositories/sc_user_repository_impl.dart`