yumi-flutter/lib/modules/room/voice_room_page.dart
2026-05-07 10:06:02 +08:00

1112 lines
36 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/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_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/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/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 {
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,
);
late TabController _tabController;
final List<Widget> _pages = [AllChatPage(), ChatPage(), GiftChatPage()];
late StreamSubscription _subscription;
final RoomGiftSeatFlightController _giftSeatFlightController =
RoomGiftSeatFlightController();
final Map<String, _LuckyGiftComboSession> _luckyGiftComboSessions =
<String, _LuckyGiftComboSession>{};
final Map<String, Timer> _giftFlightBatchDedupeTimers = <String, Timer>{};
int _shownRoomStartupFailureToken = 0;
RtcProvider? _rtcProvider;
bool _roomProviderRebuildScheduled = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: _pages.length, vsync: this);
_enableRoomVisualEffects();
_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();
_giftSeatFlightController.clear();
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rtcProvider = context.read<RtcProvider>();
if (!identical(_rtcProvider, rtcProvider)) {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = rtcProvider;
rtcProvider.addListener(_handleRoomProviderChanged);
}
_ensureRoomVisualEffectsEnabled();
}
@override
void dispose() {
_rtcProvider?.removeListener(_handleRoomProviderChanged);
_rtcProvider = null;
_roomProviderRebuildScheduled = false;
_suspendRoomVisualEffects();
_tabController.removeListener(_handleTabChange);
_tabController.dispose(); // 释放资源
_subscription.cancel();
super.dispose();
}
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 ||
rtcProvider.roomVisualEffectsEnabled) {
return;
}
_enableRoomVisualEffects();
}
void _suspendRoomVisualEffects() {
final rtcProvider = Provider.of<RtcProvider>(context, listen: 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();
_giftSeatFlightController.clear();
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
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.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) {
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(),
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(),
const RoomGiftComboFloatingLayer(),
],
),
),
),
);
}
///消息
Widget _buildChatView() {
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(_pages.length, _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: _pages,
),
),
),
),
],
),
RoomPlayWidget(),
],
),
);
}
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) {
if (!Provider.of<RtcProvider>(
context,
listen: false,
).shouldShowRoomVisualEffects) {
return;
}
if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) {
return;
}
var 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 = 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);
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
final targetUserIds = _resolveGiftTargetUserIds(msg);
final targetGroupKey = _buildGiftTargetGroupKey(targetUserIds);
if (_supportsComboMilestoneEffects(msg)) {
_handleComboMilestoneVisuals(msg, targetGroupKey);
}
final isLuckyGift = _isLuckyGiftMessage(msg);
if (isLuckyGift) {
_handleLuckyGiftComboVisuals(
msg,
giftPhoto,
targetGroupKey,
targetUserIds,
);
return;
}
_handleStandardGiftComboVisuals(
msg,
giftPhoto,
targetGroupKey,
targetUserIds,
);
}
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;
}
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(",");
}
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();
}
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,
),
),
),
),
],
),
),
),
);
}
}