1112 lines
36 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|