yumi-flutter/lib/modules/room/voice_room_page.dart
zhx 247e58e4b8 Merge branch 'feature' of gitea.haiyihy.com:hy/chatapp3-flutter into feature
# Conflicts:
#	android/app/src/main/kotlin/com/org/yumiparty/GiftMp4VideoPlatformView.kt
#	ios/Runner.xcodeproj/project.pbxproj
#	pubspec.lock
#	pubspec.yaml
2026-07-04 01:44:30 +08:00

1438 lines
46 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/main.dart' show routeObserver;
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/ui_kit/widgets/room/room_bottom_widget.dart';
import 'package:provider/provider.dart';
import 'package:yumi/shared/tools/sc_lk_event_bus.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/music/room_music_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_lucky_gift_win_sound_player.dart';
import 'package:yumi/shared/tools/sc_cp_gift_relation_utils.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/shared/tools/sc_room_top_layer_guard.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/shared/data_sources/sources/local/floating_screen_manager.dart';
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';
import 'package:yumi/ui_kit/widgets/room/anim/room_entrance_widget.dart';
import 'package:yumi/ui_kit/widgets/room/effect/luck_gift_nomor_anim_widget.dart';
import 'package:yumi/ui_kit/widgets/room/effect/vapp_svga_layer_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart';
import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_room_player.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart';
import '../../ui_kit/components/sc_float_ichart.dart';
import '../../ui_kit/widgets/room/seat/room_seat_widget.dart';
import 'chat/all/all_chat_page.dart';
import 'chat/chat/chat_page.dart';
import 'chat/gift/gift_chat_page.dart';
///语聊房
class VoiceRoomPage extends StatefulWidget {
const VoiceRoomPage({super.key});
@override
State<VoiceRoomPage> createState() => _VoiceRoomPageState();
}
class _VoiceRoomPageState extends State<VoiceRoomPage>
with SingleTickerProviderStateMixin, RouteAware {
static const int _chatTabCount = 3;
static const Duration _luckyGiftComboWindow = Duration(seconds: 3);
static const Duration _luckyGiftQueueDrainWindow = Duration(seconds: 3);
static const int _maxLuckyGiftTrackedAnimations = 5;
static const Duration _giftAnimationSessionWindow = _luckyGiftComboWindow;
static const Duration _giftAnimationQueueDrainWindow =
_luckyGiftQueueDrainWindow;
static const int _maxTrackedGiftAnimations = _maxLuckyGiftTrackedAnimations;
static const Duration _giftFlightBatchDedupeWindow = Duration(
milliseconds: 650,
);
static const Duration _giftVisualBatchDedupeWindow = Duration(seconds: 4);
late TabController _tabController;
late StreamSubscription _subscription;
final RoomGiftSeatFlightController _giftSeatFlightController =
RoomGiftSeatFlightController();
final Map<String, _LuckyGiftComboSession> _luckyGiftComboSessions =
<String, _LuckyGiftComboSession>{};
final Map<String, Timer> _giftFlightBatchDedupeTimers = <String, Timer>{};
final Map<String, Timer> _giftVisualBatchDedupeTimers = <String, Timer>{};
int _shownRoomStartupFailureToken = 0;
RtcProvider? _rtcProvider;
bool _roomProviderRebuildScheduled = false;
bool _roomRouteVisibleScheduled = false;
bool _roomVisualEffectsEnsureScheduled = false;
PageRoute<dynamic>? _routeObserverRoute;
SCRoomTopLayerGuardLease? _coveredRouteGuardLease;
bool _roomRouteVisible = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: _chatTabCount, vsync: this);
_tabController.addListener(_handleTabChange);
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
event,
) {
if (mounted) {
Provider.of<GiftProvider>(context, listen: false).clearAllGiftData();
Provider.of<GiftProvider>(
context,
listen: false,
).toggleGiftAnimationVisibility(false);
_clearLuckyGiftComboSessions();
_clearGiftFlightBatchDedupeTimers();
_clearGiftVisualBatchDedupeTimers();
_giftSeatFlightController.clear();
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rtcProvider = context.read<RtcProvider>();
if (!identical(_rtcProvider, rtcProvider)) {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = rtcProvider;
rtcProvider.addListener(_handleRoomProviderChanged);
context.read<RoomMusicManager>().attachRtcProvider(rtcProvider);
}
final route = ModalRoute.of(context);
if (route is PageRoute<dynamic> && _routeObserverRoute != route) {
if (_routeObserverRoute != null) {
routeObserver.unsubscribe(this);
}
_routeObserverRoute = route;
routeObserver.subscribe(this, route);
if (route.isCurrent) {
_scheduleRoomRouteVisible();
} else {
_handleRoomRouteCovered();
}
}
_scheduleEnsureRoomVisualEffectsEnabled();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_routeObserverRoute = null;
_releaseCoveredRouteGuard();
_setRoomRouteVisible(false);
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = null;
_roomProviderRebuildScheduled = false;
_suspendRoomVisualEffects();
_tabController.removeListener(_handleTabChange);
_tabController.dispose(); // 释放资源
_subscription.cancel();
super.dispose();
}
@override
void didPush() {
_handleRoomRouteVisible();
}
@override
void didPopNext() {
_handleRoomRouteVisible();
}
@override
void didPushNext() {
_handleRoomRouteCovered();
}
@override
void didPop() {
_handleRoomRouteCovered();
_releaseCoveredRouteGuard();
}
void _handleRoomRouteVisible() {
_releaseCoveredRouteGuard();
_setRoomRouteVisible(true);
}
void _scheduleRoomRouteVisible() {
if (_roomRouteVisibleScheduled) {
return;
}
_roomRouteVisibleScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomRouteVisibleScheduled = false;
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) {
return;
}
_handleRoomRouteVisible();
_ensureRoomVisualEffectsEnabled();
});
}
void _handleRoomRouteCovered() {
_setRoomRouteVisible(false);
_acquireCoveredRouteGuard();
_clearTransientRoomVisualEffects(reason: 'voice_room_route_covered');
}
void _acquireCoveredRouteGuard() {
_coveredRouteGuardLease ??= SCRoomTopLayerGuard().acquire(
reason: 'voice_room_route_covered',
);
}
void _releaseCoveredRouteGuard() {
_coveredRouteGuardLease?.release();
_coveredRouteGuardLease = null;
}
void _setRoomRouteVisible(bool visible) {
if (_roomRouteVisible == visible) {
return;
}
_roomRouteVisible = visible;
final rtcProvider =
_rtcProvider ?? Provider.of<RtcProvider>(context, listen: false);
rtcProvider.setVoiceRoomRouteVisible(visible);
}
void _handleTabChange() {
if (!mounted) {
return;
}
setState(() {});
}
void _handleRoomProviderChanged() {
if (!mounted || _roomProviderRebuildScheduled) {
return;
}
_roomProviderRebuildScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomProviderRebuildScheduled = false;
if (!mounted) {
return;
}
setState(() {});
});
}
void _enableRoomVisualEffects() {
Provider.of<RtcProvider>(
context,
listen: false,
).setRoomVisualEffectsEnabled(true);
final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
rtmProvider.msgFloatingGiftListener = _floatingGiftListener;
rtmProvider.msgLuckyGiftRewardTickerListener =
_luckyGiftRewardTickerListener;
}
void _ensureRoomVisualEffectsEnabled() {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
if (rtcProvider.currenRoom == null ||
!_roomRouteVisible ||
rtcProvider.roomVisualEffectsEnabled) {
return;
}
_enableRoomVisualEffects();
}
void _scheduleEnsureRoomVisualEffectsEnabled() {
if (_roomVisualEffectsEnsureScheduled) {
return;
}
_roomVisualEffectsEnsureScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_roomVisualEffectsEnsureScheduled = false;
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) {
return;
}
_ensureRoomVisualEffectsEnabled();
});
}
void _clearTransientRoomVisualEffects({required String reason}) {
if (!mounted) {
return;
}
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
RoomEntranceHelper.clearQueue();
Provider.of<GiftProvider>(context, listen: false).clearAllGiftData();
Provider.of<GiftAnimationManager>(
context,
listen: false,
).clearActiveAnimations();
_clearLuckyGiftComboSessions();
_clearGiftFlightBatchDedupeTimers();
_clearGiftVisualBatchDedupeTimers();
_giftSeatFlightController.clear();
OverlayManager().removeRoom(roomId: currentRoomId);
SCRoomEffectScheduler().clearDeferredTasks(reason: reason);
SCGiftVapSvgaManager().stopPlayback();
}
void _suspendRoomVisualEffects() {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
rtcProvider.setVoiceRoomRouteVisible(false);
rtcProvider.setRoomVisualEffectsEnabled(false);
final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
if (rtmProvider.msgFloatingGiftListener == _floatingGiftListener) {
rtmProvider.msgFloatingGiftListener = null;
}
if (rtmProvider.msgLuckyGiftRewardTickerListener ==
_luckyGiftRewardTickerListener) {
rtmProvider.msgLuckyGiftRewardTickerListener = null;
}
RoomEntranceHelper.clearQueue();
_clearLuckyGiftComboSessions();
_clearGiftFlightBatchDedupeTimers();
_clearGiftVisualBatchDedupeTimers();
_giftSeatFlightController.clear();
OverlayManager().removeRoom(roomId: currentRoomId);
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
if (!SCFloatIchart().isShow()) {
unawaited(
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
);
}
}
Widget _buildRoomStartupLayer() {
return Selector<RtcProvider, _RoomStartupSnapshot>(
selector:
(context, provider) => _RoomStartupSnapshot(
status: provider.roomStartupStatus,
failureType: provider.roomStartupFailureType,
failureToken: provider.roomStartupFailureToken,
),
builder: (context, snapshot, child) {
if (snapshot.status == RoomStartupStatus.failed) {
_showRoomStartupFailureDialog(snapshot);
return const SizedBox.shrink();
}
if (snapshot.status != RoomStartupStatus.loading) {
return const SizedBox.shrink();
}
return const SizedBox.shrink();
},
);
}
void _showRoomStartupFailureDialog(_RoomStartupSnapshot snapshot) {
if (_shownRoomStartupFailureToken == snapshot.failureToken) {
return;
}
_shownRoomStartupFailureToken = snapshot.failureToken;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
SmartDialog.dismiss(tag: "roomStartupFailureDialog");
SmartDialog.show(
tag: "roomStartupFailureDialog",
alignment: Alignment.center,
animationType: SmartAnimationType.fade,
backType: SmartBackType.block,
clickMaskDismiss: false,
builder: (_) {
return _RoomStartupFailureDialog(
title: SCAppLocalizations.of(context)!.tips,
message: _roomStartupFailureMessage(snapshot.failureType),
btnText: SCAppLocalizations.of(context)!.confirm,
onEnsure: () {
SmartDialog.dismiss(tag: "roomStartupFailureDialog");
context.read<RtcProvider>().confirmRoomStartupFailureAndLeave(
navigationContext: context,
);
},
);
},
);
});
}
String _roomStartupFailureMessage(RoomStartupFailureType failureType) {
switch (failureType) {
case RoomStartupFailureType.im:
return SCAppLocalizations.of(context)!.enterRoomFailedRetry;
case RoomStartupFailureType.rtc:
return SCAppLocalizations.of(context)!.voiceConnectionFailedRetry;
case RoomStartupFailureType.kicked:
return SCAppLocalizations.of(context)!.roomKickedEntryBlockedTips;
case RoomStartupFailureType.entry:
case RoomStartupFailureType.none:
return SCAppLocalizations.of(context)!.operationFail;
}
}
String? _resolveRoomBackground(JoinRoomRes? room) {
final roomBackground =
room?.roomProfile?.roomProfile?.roomBackground?.trim() ?? "";
if (roomBackground.isNotEmpty) {
return roomBackground;
}
final roomTheme = room?.roomProps?.roomTheme;
final themeBack = roomTheme?.themeBack ?? "";
if (themeBack.isEmpty) {
return null;
}
if ((roomTheme?.expireTime ?? 0) <= DateTime.now().millisecondsSinceEpoch) {
return null;
}
return themeBack;
}
@override
Widget build(BuildContext context) {
final chatRoomKey = _chatRoomKey(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) {
if (!didPop) {
final rtcProvider = context.read<RtcProvider>();
if (rtcProvider.roomStartupStatus == RoomStartupStatus.loading ||
rtcProvider.roomStartupStatus == RoomStartupStatus.failed) {
return;
}
_suspendRoomVisualEffects();
SCFloatIchart().show();
SCNavigatorUtils.goBack(context);
}
},
child: Scaffold(
backgroundColor:
SCGlobalConfig.businessLogicStrategy.getVoiceRoomBackgroundColor(),
resizeToAvoidBottomInset: false,
body: SafeArea(
top: false,
child: Stack(
children: [
Selector<RtcProvider, String?>(
selector:
(context, provider) =>
_resolveRoomBackground(provider.currenRoom),
builder: (context, roomBackground, child) {
return roomBackground != null
? netImage(
url: roomBackground,
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight,
noDefaultImg: true,
fit: BoxFit.cover,
)
: Image.asset(
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomDefaultBackgroundImage(),
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight,
fit: BoxFit.cover,
);
},
),
Column(
children: [
SizedBox(height: ScreenUtil().setWidth(42)),
RoomHeadWidget(),
SizedBox(height: ScreenUtil().setWidth(5)),
RoomOnlineUserWidget(),
RoomSeatWidget(),
SizedBox(height: 2.w),
Expanded(
child: Stack(
children: [
Column(
children: [
_buildChatView(chatRoomKey),
const RoomBottomWidget(showGiftComboButton: false),
],
),
PositionedDirectional(
start: 0,
end: 0,
top: 0,
bottom: RoomBottomWidget.floatingButtonHostHeight.w,
child: const IgnorePointer(child: LGiftAnimalPage()),
),
Transform.translate(
offset: Offset(0, -20),
child: RoomAnimationQueueScreen(),
),
],
),
),
],
),
_buildRoomStartupLayer(),
const PositionedDirectional(
start: 0,
end: 0,
bottom: 0,
child: RoomMusicRoomPlayer(),
),
// _buildPlayViews(),
///幸运礼物中奖动画
LuckGiftNomorAnimWidget(),
_buildRoomGiftSvgaLayer(),
_buildRoomGiftSeatFlightLayer(),
const RoomGiftComboFloatingLayer(),
],
),
),
),
);
}
Widget _buildRoomGiftSvgaLayer() {
if (!SCGlobalConfig.allowsHighCostAnimations) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: Selector<RtcProvider, bool>(
selector: (context, provider) => provider.shouldShowRoomVisualEffects,
builder: (context, shouldShowRoomVisualEffects, child) {
if (!shouldShowRoomVisualEffects) {
return const SizedBox.shrink();
}
return child!;
},
child: const VapPlusSvgaPlayer(tag: "room_gift"),
),
);
}
Widget _buildRoomGiftSeatFlightLayer() {
return Positioned.fill(
child: Selector<RtcProvider, bool>(
selector: (context, provider) => provider.shouldShowRoomVisualEffects,
builder: (context, shouldShowRoomVisualEffects, child) {
if (!shouldShowRoomVisualEffects) {
return const SizedBox.shrink();
}
return RoomGiftSeatFlightOverlay(
controller: _giftSeatFlightController,
resolveTargetKey:
(userId) =>
context.read<RtcProvider>().getSeatGlobalKeyByIndex(userId),
);
},
),
);
}
///消息
Widget _buildChatView(String chatRoomKey) {
return Expanded(
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
Column(
children: [
TabBar(
tabAlignment: TabAlignment.start,
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.all(Colors.transparent),
labelPadding: EdgeInsets.symmetric(horizontal: 8.w),
labelColor:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabLabelColor(),
isScrollable: true,
indicator: BoxDecoration(),
unselectedLabelColor:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabUnselectedLabelColor(),
labelStyle: SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabLabelStyle()
.copyWith(
fontSize:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabLabelStyle()
.fontSize! *
ScreenUtil().setSp(1),
),
unselectedLabelStyle: SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabUnselectedLabelStyle()
.copyWith(
fontSize:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabUnselectedLabelStyle()
.fontSize! *
ScreenUtil().setSp(1),
),
indicatorColor:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabIndicatorColor(),
dividerColor:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomTabDividerColor(),
controller: _tabController,
tabs: List<Widget>.generate(_chatTabCount, _buildImageTab),
),
Expanded(
child: Container(
margin: SCGlobalConfig.businessLogicStrategy
.getVoiceRoomChatContainerMargin()
.copyWith(
end:
SCGlobalConfig.businessLogicStrategy
.getVoiceRoomChatContainerMargin()
.end *
ScreenUtil().setWidth(1),
),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: TabBarView(
controller: _tabController,
children: _buildChatPages(chatRoomKey),
),
),
),
),
],
),
RoomPlayWidget(),
],
),
);
}
String _chatRoomKey(BuildContext context) {
final roomProfile =
context.read<RtcProvider>().currenRoom?.roomProfile?.roomProfile;
final roomId = roomProfile?.id?.trim() ?? "";
final groupId = roomProfile?.roomAccount?.trim() ?? "";
return "$roomId|$groupId";
}
List<Widget> _buildChatPages(String chatRoomKey) {
return <Widget>[
KeyedSubtree(
key: ValueKey("all_chat_$chatRoomKey"),
child: const AllChatPage(),
),
KeyedSubtree(key: ValueKey("chat_$chatRoomKey"), child: ChatPage()),
KeyedSubtree(
key: ValueKey("gift_chat_$chatRoomKey"),
child: const GiftChatPage(),
),
];
}
Widget _buildImageTab(int index) {
return Tab(
height: 32.w,
child: IgnorePointer(
child: Image.asset(
_roomChatTabAsset(index),
height: 26.w,
fit: BoxFit.contain,
),
),
);
}
String _roomChatTabAsset(int index) {
final bool selected = _tabController.index == index;
switch (index) {
case 0:
return selected
? "sc_images/room/sc_icon_room_chat_tab_all_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_all_unselected.png";
case 1:
return selected
? "sc_images/room/sc_icon_room_chat_tab_chat_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_chat_unselected.png";
case 2:
default:
return selected
? "sc_images/room/sc_icon_room_chat_tab_gift_selected.png"
: "sc_images/room/sc_icon_room_chat_tab_gift_unselected.png";
}
}
///礼物上飘动画
_floatingGiftListener(Msg msg) {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
if (!rtcProvider.shouldShowRoomVisualEffects) {
return;
}
if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) {
return;
}
final targetUserIds = _resolveGiftTargetUserIds(msg);
final targetGroupKey = _buildGiftTargetGroupKey(targetUserIds);
final shouldPlayBatchVisuals =
targetUserIds.length <= 1 ||
targetGroupKey == null ||
_markGiftVisualBatchForPlayback(msg, targetGroupKey);
if (shouldPlayBatchVisuals) {
var giftModel = LGiftModel();
giftModel.labelId = _buildGiftTickerLabelId(msg, targetGroupKey);
giftModel.sendUserName = msg.user?.userNickname ?? "";
giftModel.sendToUserName = _resolveGiftTickerTargetName(
msg,
targetUserIds,
);
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<GiftAnimationManager>(
context,
listen: false,
).enqueueGiftAnimation(giftModel);
} else {
return;
}
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
_enqueueCpGiftFloatingIfNeeded(msg);
if (_supportsComboMilestoneEffects(msg)) {
_handleComboMilestoneVisuals(msg, targetGroupKey);
}
final isLuckyGift = _isLuckyGiftMessage(msg);
if (isLuckyGift) {
_handleLuckyGiftComboVisuals(
msg,
giftPhoto,
targetGroupKey,
targetUserIds,
);
return;
}
_handleStandardGiftComboVisuals(
msg,
giftPhoto,
targetGroupKey,
targetUserIds,
);
}
void _enqueueCpGiftFloatingIfNeeded(Msg msg) {
final gift = msg.gift;
if (!scIsCpGift(gift) || gift == null) {
return;
}
final quantity = msg.number ?? 0;
if (quantity <= 0) {
return;
}
final roomId = _resolveGiftFloatingRoomId(msg);
OverlayManager().addMessage(
SCFloatingMessage(
type: 1,
priority: 900,
userId: msg.user?.id ?? "",
toUserId: msg.toUser?.id ?? "",
userAvatarUrl: msg.user?.userAvatar ?? "",
toUserAvatarUrl: msg.toUser?.userAvatar ?? "",
userName: msg.user?.userNickname ?? "",
toUserName: msg.toUser?.userNickname ?? "",
giftUrl: gift.giftPhoto ?? "",
giftId: gift.id,
giftTab: scCpRelationTypeForGift(gift),
number: quantity,
coins: quantity * (gift.giftCandy ?? 0),
roomId: roomId,
),
);
}
String _resolveGiftFloatingRoomId(Msg msg) {
final messageRoomId = (msg.msg ?? "").trim();
if (messageRoomId.isNotEmpty) {
return messageRoomId;
}
return Provider.of<RtcProvider>(
context,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.id ??
"";
}
void _luckyGiftRewardTickerListener(Msg msg) {
if (!Provider.of<RtcProvider>(
context,
listen: false,
).shouldShowRoomVisualEffects) {
return;
}
if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) {
return;
}
final awardAmount = msg.awardAmount ?? 0;
if (awardAmount <= 0) {
return;
}
final giftModel = LGiftModel();
giftModel.labelId = "${msg.gift?.id}${msg.user?.id}${msg.toUser?.id}";
giftModel.sendUserName = msg.user?.userNickname ?? "";
giftModel.sendToUserName = msg.toUser?.userNickname ?? "";
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
giftModel.giftCount = 0;
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<GiftAnimationManager>(
context,
listen: false,
).enqueueGiftAnimation(giftModel);
unawaited(
SCLuckyGiftWinSoundPlayer.play(
eventKey: SCLuckyGiftWinSoundPlayer.buildEventKey(
giftId: msg.gift?.id,
userId: msg.user?.id,
toUserId: msg.toUser?.id,
awardAmount: awardAmount,
),
),
);
}
String _formatLuckyRewardAmount(num awardAmount) {
if (awardAmount > 9999) {
return "${(awardAmount / 1000).toStringAsFixed(0)}k";
}
if (awardAmount % 1 == 0) {
return awardAmount.toInt().toString();
}
return awardAmount.toString();
}
bool _isLuckyGiftMessage(Msg msg) {
final giftTab = (msg.gift?.giftTab ?? '').trim();
return giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name;
}
bool _supportsComboMilestoneEffects(Msg msg) {
return SocialChatGiftSystemManager.supportsComboMilestoneEffects(msg.gift);
}
bool _shouldPlayComboMilestoneEffect(Msg msg) {
if (!_supportsComboMilestoneEffects(msg)) {
return false;
}
return SCGlobalConfig.isLuckGiftSpecialEffects;
}
void _handleComboMilestoneVisuals(Msg msg, String? targetUserId) {
final quantity = (msg.number ?? 0).floor();
if (quantity <= 0) {
return;
}
final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetUserId);
final session = _luckyGiftComboSessions.putIfAbsent(
sessionKey,
() => _LuckyGiftComboSession(),
);
session.endTimer?.cancel();
session.clearQueueTimer?.cancel();
final previousTotal = session.totalCount;
session.totalCount += quantity;
final highestMilestone =
SocialChatGiftSystemManager.resolveHighestCrossedComboEffectMilestone(
previousCount: previousTotal,
currentCount: session.totalCount,
);
if (highestMilestone != null &&
highestMilestone > session.highestPlayedMilestone &&
_shouldPlayComboMilestoneEffect(msg)) {
final effectPath =
SocialChatGiftSystemManager.resolveComboMilestoneEffectPath(
highestMilestone,
);
if (effectPath != null && effectPath.isNotEmpty) {
SCGiftVapSvgaManager().play(effectPath, priority: 200);
session.highestPlayedMilestone = highestMilestone;
}
}
}
void _handleStandardGiftComboVisuals(
Msg msg,
String giftPhoto,
String? targetGroupKey,
List<String> targetUserIds,
) {
if ((msg.number ?? 0) <= 0) {
return;
}
if (!_shouldPlaySeatFlightGiftAnimation(msg) ||
targetGroupKey == null ||
targetUserIds.isEmpty ||
giftPhoto.isEmpty) {
return;
}
final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds);
if (targetUserIds.length > 1 &&
!_markGiftFlightBatchForPlayback(flightBatchKey)) {
return;
}
final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey);
final session = _luckyGiftComboSessions.putIfAbsent(
sessionKey,
() => _LuckyGiftComboSession(),
);
session.endTimer?.cancel();
session.clearQueueTimer?.cancel();
_enqueueTrackedSeatFlightAnimations(
sessionKey: sessionKey,
giftPhoto: giftPhoto,
targetUserIds: targetUserIds,
animationCount: _resolveTrackedAnimationCount(msg),
batchKey: flightBatchKey,
);
_scheduleGiftAnimationSessionEnd(sessionKey);
}
void _handleLuckyGiftComboVisuals(
Msg msg,
String giftPhoto,
String? targetGroupKey,
List<String> targetUserIds,
) {
if ((msg.number ?? 0) <= 0) {
return;
}
if (targetGroupKey == null || targetUserIds.isEmpty) {
return;
}
final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey);
final session = _luckyGiftComboSessions[sessionKey];
if (session == null) {
return;
}
session.endTimer?.cancel();
session.clearQueueTimer?.cancel();
if (_shouldPlaySeatFlightGiftAnimation(msg) && giftPhoto.isNotEmpty) {
final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds);
if (targetUserIds.length > 1 &&
!_markGiftFlightBatchForPlayback(flightBatchKey)) {
_scheduleGiftAnimationSessionEnd(sessionKey);
return;
}
_enqueueTrackedSeatFlightAnimations(
sessionKey: sessionKey,
giftPhoto: giftPhoto,
targetUserIds: targetUserIds,
animationCount: _resolveTrackedAnimationCount(msg),
batchKey: flightBatchKey,
);
}
_scheduleGiftAnimationSessionEnd(sessionKey);
}
int _resolveTrackedAnimationCount(Msg msg) {
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 List<String> targetUserIds,
required int animationCount,
required String batchKey,
}) {
unawaited(
warmImageResource(giftPhoto, logicalWidth: 96.w, logicalHeight: 96.w),
);
final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds);
if (normalizedTargetUserIds.isEmpty) {
return;
}
final normalizedAnimationCount = math.max(animationCount, 1);
final cappedAnimationCount = math.min(
normalizedAnimationCount,
_maxTrackedGiftAnimations,
);
final maxTrackedRequests = math.max(
_maxTrackedGiftAnimations * normalizedTargetUserIds.length,
_maxTrackedGiftAnimations,
);
for (var index = 0; index < cappedAnimationCount; index += 1) {
final roundBatchTag =
normalizedTargetUserIds.length > 1 ? '$batchKey|round:$index' : null;
for (final targetUserId in normalizedTargetUserIds) {
_giftSeatFlightController.enqueueLimited(
RoomGiftSeatFlightRequest(
imagePath: giftPhoto,
targetUserId: targetUserId,
beginSize: 96.w,
endSize: 28.w,
queueTag: sessionKey,
batchTag: roundBatchTag,
),
maxTrackedRequests: maxTrackedRequests,
);
}
}
}
void _scheduleGiftAnimationSessionEnd(String sessionKey) {
final session = _luckyGiftComboSessions[sessionKey];
if (session == null) {
return;
}
session.endTimer?.cancel();
session.endTimer = Timer(_giftAnimationSessionWindow, () {
if (!mounted) {
return;
}
final activeSession = _luckyGiftComboSessions[sessionKey];
if (activeSession == null) {
return;
}
if (!_giftSeatFlightController.hasTrackedRequests(sessionKey)) {
activeSession.dispose();
_luckyGiftComboSessions.remove(sessionKey);
return;
}
activeSession.clearQueueTimer?.cancel();
activeSession.clearQueueTimer = Timer(_giftAnimationQueueDrainWindow, () {
_giftSeatFlightController.clearQueuedRequests(sessionKey);
final expiredSession = _luckyGiftComboSessions.remove(sessionKey);
expiredSession?.dispose();
});
});
}
List<String> _resolveGiftTargetUserIds(Msg msg) {
final targetUserIds = <String>[];
void addTargetUserId(String? userId) {
final normalizedUserId = (userId ?? "").trim();
if (normalizedUserId.isEmpty ||
targetUserIds.contains(normalizedUserId)) {
return;
}
targetUserIds.add(normalizedUserId);
}
for (final userId in msg.targetUserIds ?? const <String>[]) {
addTargetUserId(userId);
}
if (msg.type == SCRoomMsgType.luckGiftAnimOther) {
final rawTargetUserIds = (msg.msg ?? "").trim();
if (rawTargetUserIds.startsWith("[")) {
try {
final decoded = jsonDecode(rawTargetUserIds);
if (decoded is List) {
for (final userId in decoded) {
addTargetUserId(userId?.toString());
}
}
} catch (_) {}
}
}
addTargetUserId(_resolveGiftTargetUserId(msg));
return targetUserIds;
}
String _buildGiftTickerLabelId(Msg msg, String? targetGroupKey) {
final giftId = (msg.gift?.id ?? "").trim();
final senderId = (msg.user?.id ?? "").trim();
if ((targetGroupKey ?? "").trim().isNotEmpty) {
return "$giftId|$senderId|$targetGroupKey";
}
return "$giftId|$senderId|${msg.toUser?.id ?? ""}";
}
String _resolveGiftTickerTargetName(Msg msg, List<String> targetUserIds) {
if (targetUserIds.length > 1) {
return SCAppLocalizations.of(context)?.multiple ?? "Multiple";
}
return msg.toUser?.userNickname ?? "";
}
List<String> _normalizeGiftTargetUserIds(List<String> targetUserIds) {
final normalizedTargetUserIds = <String>[];
for (final targetUserId in targetUserIds) {
final normalizedTargetUserId = targetUserId.trim();
if (normalizedTargetUserId.isEmpty ||
normalizedTargetUserIds.contains(normalizedTargetUserId)) {
continue;
}
normalizedTargetUserIds.add(normalizedTargetUserId);
}
return normalizedTargetUserIds;
}
String? _buildGiftTargetGroupKey(List<String> targetUserIds) {
final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds);
if (normalizedTargetUserIds.isEmpty) {
return null;
}
normalizedTargetUserIds.sort();
return normalizedTargetUserIds.join(",");
}
bool _markGiftVisualBatchForPlayback(Msg msg, String targetGroupKey) {
final batchKey = _buildGiftVisualBatchKey(msg, targetGroupKey);
if (batchKey.isEmpty) {
return true;
}
if (_giftVisualBatchDedupeTimers.containsKey(batchKey)) {
return false;
}
final hasStableBatchId = (msg.giftBatchId ?? "").trim().isNotEmpty;
final dedupeWindow =
hasStableBatchId
? _giftVisualBatchDedupeWindow
: _giftFlightBatchDedupeWindow;
late final Timer timer;
timer = Timer(dedupeWindow, () {
if (identical(_giftVisualBatchDedupeTimers[batchKey], timer)) {
_giftVisualBatchDedupeTimers.remove(batchKey);
}
});
_giftVisualBatchDedupeTimers[batchKey] = timer;
return true;
}
String _buildGiftVisualBatchKey(Msg msg, String targetGroupKey) {
final stableBatchId = (msg.giftBatchId ?? "").trim();
if (stableBatchId.isNotEmpty) {
return "batch|$stableBatchId";
}
final timeBucket =
((msg.time ?? DateTime.now().millisecondsSinceEpoch) / 150).floor();
return [
"legacy",
msg.type ?? "",
msg.groupId ?? "",
msg.gift?.id ?? "",
msg.user?.id ?? "",
targetGroupKey,
msg.number?.toString() ?? "",
msg.customAnimationCount?.toString() ?? "",
timeBucket.toString(),
].join("|");
}
String _buildGiftFlightBatchKey(Msg msg, List<String> targetUserIds) {
final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds);
normalizedTargetUserIds.sort();
final timeBucket =
((msg.time ?? DateTime.now().millisecondsSinceEpoch) / 650).floor();
return [
msg.type ?? "",
msg.gift?.id ?? "",
msg.user?.id ?? "",
msg.number?.toString() ?? "",
msg.customAnimationCount?.toString() ?? "",
normalizedTargetUserIds.join(","),
timeBucket.toString(),
].join("|");
}
bool _markGiftFlightBatchForPlayback(String batchKey) {
final normalizedBatchKey = batchKey.trim();
if (normalizedBatchKey.isEmpty) {
return true;
}
if (_giftFlightBatchDedupeTimers.containsKey(normalizedBatchKey)) {
return false;
}
late final Timer timer;
timer = Timer(_giftFlightBatchDedupeWindow, () {
if (identical(_giftFlightBatchDedupeTimers[normalizedBatchKey], timer)) {
_giftFlightBatchDedupeTimers.remove(normalizedBatchKey);
}
});
_giftFlightBatchDedupeTimers[normalizedBatchKey] = timer;
return true;
}
String _buildLuckyGiftComboSessionKey(Msg msg, String? targetUserId) {
final senderId = (msg.user?.id ?? '').trim();
final giftId = (msg.gift?.id ?? '').trim();
return '$giftId|$senderId|${targetUserId ?? ""}';
}
void _clearLuckyGiftComboSessions() {
for (final session in _luckyGiftComboSessions.values) {
session.dispose();
}
_luckyGiftComboSessions.clear();
}
void _clearGiftFlightBatchDedupeTimers() {
for (final timer in _giftFlightBatchDedupeTimers.values) {
timer.cancel();
}
_giftFlightBatchDedupeTimers.clear();
}
void _clearGiftVisualBatchDedupeTimers() {
for (final timer in _giftVisualBatchDedupeTimers.values) {
timer.cancel();
}
_giftVisualBatchDedupeTimers.clear();
}
bool _shouldPlaySeatFlightGiftAnimation(Msg msg) {
final gift = msg.gift;
if (gift == null) {
return false;
}
final giftPhoto = (gift.giftPhoto ?? "").trim();
if (giftPhoto.isEmpty) {
return false;
}
final giftPhotoExt = _normalizedGiftResourceExtension(giftPhoto);
if (_isAnimatedGiftResource(giftPhotoExt)) {
return false;
}
final giftSourceUrl = (gift.giftSourceUrl ?? "").trim();
final sourceExt = _normalizedGiftResourceExtension(giftSourceUrl);
return !_isAnimatedGiftResource(sourceExt);
}
bool _isAnimatedGiftResource(String extension) {
return extension == ".svga" || extension == ".mp4" || extension == ".vap";
}
String _normalizedGiftResourceExtension(String resource) {
final value = resource.trim();
if (value.isEmpty) {
return "";
}
final uri = Uri.tryParse(value);
if (uri != null && ((uri.scheme.isNotEmpty) || (uri.host.isNotEmpty))) {
return SCPathUtils.getFileExtension(uri.path).toLowerCase();
}
final normalizedValue = value.split("?").first.split("#").first;
return SCPathUtils.getFileExtension(normalizedValue).toLowerCase();
}
String? _resolveGiftTargetUserId(Msg msg) {
final directUserId = (msg.toUser?.id ?? "").trim();
if (directUserId.isNotEmpty) {
return directUserId;
}
final targetAccount = (msg.toUser?.account ?? "").trim();
if (targetAccount.isEmpty) {
return null;
}
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
for (final micRes in rtcProvider.roomWheatMap.values) {
if ((micRes.user?.account ?? "").trim() == targetAccount) {
final userId = (micRes.user?.id ?? "").trim();
if (userId.isNotEmpty) {
return userId;
}
}
}
return null;
}
}
class _LuckyGiftComboSession {
Timer? endTimer;
Timer? clearQueueTimer;
int totalCount = 0;
int highestPlayedMilestone = 0;
void dispose() {
endTimer?.cancel();
clearQueueTimer?.cancel();
}
}
class _RoomStartupSnapshot {
const _RoomStartupSnapshot({
required this.status,
required this.failureType,
required this.failureToken,
});
final RoomStartupStatus status;
final RoomStartupFailureType failureType;
final int failureToken;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomStartupSnapshot &&
other.status == status &&
other.failureType == failureType &&
other.failureToken == failureToken;
}
@override
int get hashCode => Object.hash(status, failureType, failureToken);
}
class _RoomStartupFailureDialog extends StatelessWidget {
const _RoomStartupFailureDialog({
required this.title,
required this.message,
required this.btnText,
required this.onEnsure,
});
final String title;
final String message;
final String btnText;
final VoidCallback onEnsure;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 320.w),
margin: EdgeInsets.symmetric(horizontal: 22.w),
padding: EdgeInsets.fromLTRB(18.w, 18.w, 18.w, 30.w),
decoration: BoxDecoration(
color: const Color(0xff09372E),
borderRadius: BorderRadius.circular(12.w),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w700,
decoration: TextDecoration.none,
),
),
SizedBox(height: 14.w),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
height: 1.25,
decoration: TextDecoration.none,
),
),
SizedBox(height: 18.w),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onEnsure,
child: Container(
width: 160.w,
height: 38.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xff18F2B1),
borderRadius: BorderRadius.circular(12.w),
),
child: Text(
btnText,
style: TextStyle(
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
decoration: TextDecoration.none,
),
),
),
),
],
),
),
),
);
}
}