修复一些问题

This commit is contained in:
NIGGER SLAYER 2026-04-20 22:40:22 +08:00
parent 5b0b5b862e
commit 743f761dd1
28 changed files with 3128 additions and 2043 deletions

View File

@ -304,6 +304,7 @@
"warning": "تحذير",
"ownerSendTheRedEnvelope": "أرسل مالك الغرفة عملات المكافأة.",
"rewardCoins": "عملات المكافأة:{1} عملة",
"signInRewardReceived": "تم تسجيل الدخول بنجاح. المكافأة: {1}",
"lastWeekProgress": "تقدم الأسبوع الماضي",
"currentProgress": "التقدم الحالي",
"coins2": "{1} عملات",

View File

@ -159,6 +159,7 @@
"roomReward": "রুম পুরস্কার",
"ownerSendTheRedEnvelope": "মালিক পুরস্কার কয়েন পাঠিয়েছেন।",
"rewardCoins": "পুরস্কার কয়েন:{1} কয়েন",
"signInRewardReceived": "সাইন ইন সফল হয়েছে। পুরস্কার: {1}",
"lastWeekProgress": "গত সপ্তাহের অগ্রগতি",
"redEnvelopeTips2": "*লাল খাম সময়সীমার মধ্যে দাবি না করলে, বাকি কয়েন প্রেরক ব্যবহারকারীকে ফেরত দেওয়া হবে।",
"goToRecharge": "রিচার্জ করতে যান",

View File

@ -131,6 +131,7 @@
"expirationTime": "Expiration time",
"ownerSendTheRedEnvelope": "The owner sent reward coins.",
"rewardCoins": "Reward coins:{1} coins",
"signInRewardReceived": "Signed in successfully. Reward: {1}",
"lastWeekProgress": "Last week's progress",
"redEnvelopeTips2": "*If the red envelope is not claimed within the time limit, the remaining coins will be returned to the user who sent the red envelope.",
"goToRecharge": "Go to recharge",

View File

@ -120,6 +120,7 @@
"roomReward": "Oda Ödülü",
"ownerSendTheRedEnvelope": "Sahip ödül jettonlarını gönderdi.",
"rewardCoins": "Ödül jettonları:{1} jetton",
"signInRewardReceived": "Giriş başarılı. Ödül: {1}",
"lastWeekProgress": "Geçen Haftanın İlerlemesi",
"redEnvelopeTips2": "*Kırmızı zarf zaman sınırı içinde talep edilmezse, kalan jettonlar gönderen kullanıcıya iade edilecektir.",
"goToRecharge": "Yüklemeye Git",

View File

@ -16,7 +16,7 @@ class SCVariant1Config implements AppConfig {
@override
String get apiHost => const String.fromEnvironment(
'API_HOST',
defaultValue: 'https://jvapi.haiyihy.com/',
defaultValue: 'http://192.168.110.43:1100/',
); // 线 --dart-define=API_HOST
@override

View File

@ -1570,6 +1570,9 @@ class SCAppLocalizations {
String rewardCoins(String name) =>
translate('rewardCoins').replaceAll('{1}', name);
String signInRewardReceived(String name) =>
translate('signInRewardReceived').replaceAll('{1}', name);
String deleteAccount2(String name) =>
translate('deleteAccount2').replaceAll('{1}', name);

View File

@ -186,7 +186,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
if (gift != null &&
(gift.giftSourceUrl ?? "").isNotEmpty &&
scGiftHasFullScreenEffect(gift.special)) {
SCGiftVapSvgaManager().preload(gift.giftSourceUrl!, highPriority: true);
SCGiftVapSvgaManager().preload(gift.giftSourceUrl!);
_giftFxLog(
'preload selected gift '
'giftId=${gift.id} '
@ -1443,10 +1443,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
debouncer.debounce(
duration: Duration(milliseconds: 350),
onDebounce: () {
Provider.of<RtcProvider>(
navigatorKey.currentState!.context,
listen: false,
).retrieveMicrophoneList();
rtcProvider?.requestGiftTriggeredMicRefresh();
},
);
}

View File

@ -9,7 +9,6 @@ import 'package:yumi/app/constants/sc_room_msg_type.dart';
import 'package:provider/provider.dart';
import 'package:yumi/app/constants/sc_screen.dart';
import 'package:yumi/shared/tools/sc_path_utils.dart';
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/shared/business_logic/models/res/mic_res.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/shared/data_sources/models/enum/sc_room_special_mike_type.dart';
@ -31,10 +30,7 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
"sc_images/room/sc_icon_room_seat_heartbeat_value.png";
RtcProvider? provider;
JoinRoomRes? room;
MicRes? roomSeat;
JoinRoomRes? _cachedRoom;
MicRes? _cachedRoomSeat;
_SeatRenderSnapshot? _cachedSnapshot;
final GlobalKey _targetKey = GlobalKey();
@override
@ -46,24 +42,26 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final liveRoomSeat = provider?.roomWheatMap[widget.index];
final liveRoom = provider?.currenRoom;
if (provider?.isExitingCurrentVoiceRoomSession ?? false) {
roomSeat = liveRoomSeat ?? _cachedRoomSeat;
room = liveRoom ?? _cachedRoom;
} else {
roomSeat = liveRoomSeat;
room = liveRoom;
if (roomSeat != null) {
_cachedRoomSeat = roomSeat;
}
if (room != null) {
_cachedRoom = room;
}
}
return Selector<RtcProvider, _SeatRenderSnapshot>(
selector:
(context, provider) =>
_SeatRenderSnapshot.fromProvider(provider, widget.index),
builder: (context, liveSnapshot, child) {
final seatSnapshot = _resolveSeatSnapshot(liveSnapshot);
final resolvedHeaddress =
SCPathUtils.getFileExtension(
seatSnapshot.headdressSourceUrl,
).toLowerCase() ==
".mp4" &&
window.locale.languageCode == "ar"
? ""
: seatSnapshot.headdressSourceUrl;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
context.read<RtcProvider>().clickSite(widget.index);
},
child: Column(
children: [
SizedBox(
@ -75,26 +73,16 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
children: [
Sonic(
index: widget.index,
room: room,
specialMikeType: seatSnapshot.specialMikeType,
isGameModel: widget.isGameModel,
),
roomSeat?.user != null
seatSnapshot.hasUser
? head(
url: roomSeat?.user?.userAvatar ?? "",
url: seatSnapshot.userAvatar,
width: widget.isGameModel ? 40.w : 55.w,
headdress:
SCPathUtils.getFileExtension(
roomSeat?.user
?.getHeaddress()
?.sourceUrl ??
"",
).toLowerCase() ==
".mp4" &&
window.locale.languageCode == "ar"
? ""
: roomSeat?.user?.getHeaddress()?.sourceUrl,
headdress: resolvedHeaddress,
)
: ((roomSeat?.micLock ?? false)
: (seatSnapshot.micLock
? Image.asset(
"sc_images/room/sc_icon_seat_lock.png",
width: widget.isGameModel ? 38.w : 52.w,
@ -109,7 +97,7 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
bottom: widget.isGameModel ? 2.w : 5.w,
right: widget.isGameModel ? 2.w : 5.w,
child:
(roomSeat?.micMute ?? false)
seatSnapshot.micMute
? Image.asset(
"sc_images/room/sc_icon_room_seat_mic_mute.png",
width: 14.w,
@ -128,7 +116,7 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
),
widget.isGameModel
? Container()
: (roomSeat?.user != null
: (seatSnapshot.hasUser
? SizedBox(
width: 64.w,
child: Column(
@ -140,23 +128,21 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
mainAxisAlignment: MainAxisAlignment.center,
children: [
msgRoleTag(
roomSeat?.user?.roles ?? "",
seatSnapshot.userRoles,
width: 15.w,
height: 15.w,
),
Flexible(
child: socialchatNickNameText(
fontWeight: FontWeight.w600,
roomSeat?.user?.userNickname ?? "",
seatSnapshot.userNickname,
fontSize: 10.sp,
type: roomSeat?.user?.getVIP()?.name ?? "",
type: seatSnapshot.userVipName,
needScroll:
(roomSeat
?.user
?.userNickname
?.characters
.length ??
0) >
seatSnapshot
.userNickname
.characters
.length >
8,
),
),
@ -177,7 +163,9 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
),
SizedBox(width: 2.w),
text(
_heartbeatVaFormat(),
_heartbeatVaFormat(
seatSnapshot.userHeartbeatValue,
),
fontWeight: FontWeight.w600,
fontSize: 8.sp,
lineHeight: 1,
@ -202,25 +190,166 @@ class _SCSeatItemState extends State<SCSeatItem> with TickerProviderStateMixin {
)),
],
),
onTap: () {
Provider.of<RtcProvider>(
context,
listen: false,
).clickSite(widget.index);
);
},
);
}
String _heartbeatVaFormat() {
int value = (roomSeat?.user?.heartbeatVal ?? 0).toInt();
if (value >= 1000000) {
return "${(value / 1000000).toStringAsFixed(1)}M";
_SeatRenderSnapshot _resolveSeatSnapshot(_SeatRenderSnapshot liveSnapshot) {
if (liveSnapshot.isExitingCurrentVoiceRoomSession) {
return _cachedSnapshot ?? liveSnapshot;
}
if (value >= 10000) {
return "${(value / 1000).toStringAsFixed(0)}k";
_cachedSnapshot = liveSnapshot;
return liveSnapshot;
}
return "$value";
String _heartbeatVaFormat(num value) {
final resolvedValue = value.toInt();
if (resolvedValue >= 1000000) {
return "${(resolvedValue / 1000000).toStringAsFixed(1)}M";
}
if (resolvedValue >= 10000) {
return "${(resolvedValue / 1000).toStringAsFixed(0)}k";
}
return "$resolvedValue";
}
}
class _SeatRenderSnapshot {
const _SeatRenderSnapshot({
required this.isExitingCurrentVoiceRoomSession,
required this.specialMikeType,
required this.userId,
required this.userAvatar,
required this.headdressSourceUrl,
required this.userNickname,
required this.userRoles,
required this.userVipName,
required this.userHeartbeatValue,
required this.micLock,
required this.micMute,
});
factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) {
final roomSeat = provider.roomWheatMap[index];
final user = roomSeat?.user;
return _SeatRenderSnapshot(
isExitingCurrentVoiceRoomSession:
provider.isExitingCurrentVoiceRoomSession,
specialMikeType:
provider.currenRoom?.roomProfile?.roomSetting?.roomSpecialMikeType ??
"",
userId: user?.id ?? "",
userAvatar: user?.userAvatar ?? "",
headdressSourceUrl: user?.getHeaddress()?.sourceUrl ?? "",
userNickname: user?.userNickname ?? "",
userRoles: user?.roles ?? "",
userVipName: user?.getVIP()?.name ?? "",
userHeartbeatValue: user?.heartbeatVal ?? 0,
micLock: roomSeat?.micLock ?? false,
micMute: roomSeat?.micMute ?? false,
);
}
final bool isExitingCurrentVoiceRoomSession;
final String specialMikeType;
final String userId;
final String userAvatar;
final String headdressSourceUrl;
final String userNickname;
final String userRoles;
final String userVipName;
final num userHeartbeatValue;
final bool micLock;
final bool micMute;
bool get hasUser => userId.isNotEmpty;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _SeatRenderSnapshot &&
other.isExitingCurrentVoiceRoomSession ==
isExitingCurrentVoiceRoomSession &&
other.specialMikeType == specialMikeType &&
other.userId == userId &&
other.userAvatar == userAvatar &&
other.headdressSourceUrl == headdressSourceUrl &&
other.userNickname == userNickname &&
other.userRoles == userRoles &&
other.userVipName == userVipName &&
other.userHeartbeatValue == userHeartbeatValue &&
other.micLock == micLock &&
other.micMute == micMute;
}
@override
int get hashCode => Object.hash(
isExitingCurrentVoiceRoomSession,
specialMikeType,
userId,
userAvatar,
headdressSourceUrl,
userNickname,
userRoles,
userVipName,
userHeartbeatValue,
micLock,
micMute,
);
}
class _SeatEmojiSnapshot {
const _SeatEmojiSnapshot({
required this.userId,
required this.emojiPath,
required this.type,
required this.number,
});
factory _SeatEmojiSnapshot.fromMic(MicRes? mic) {
final emojiPath = mic?.emojiPath ?? "";
return _SeatEmojiSnapshot(
userId: mic?.user?.id ?? "",
emojiPath: emojiPath.isEmpty ? null : emojiPath,
type: mic?.type ?? "",
number: mic?.number ?? "",
);
}
final String userId;
final String? emojiPath;
final String type;
final String number;
bool get hasUser => userId.isNotEmpty;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _SeatEmojiSnapshot &&
other.userId == userId &&
other.emojiPath == emojiPath &&
other.type == type &&
other.number == number;
}
@override
int get hashCode => Object.hash(userId, emojiPath, type, number);
}
class _SeatEmojiEvent {
const _SeatEmojiEvent({required this.type, required this.number});
final String type;
final String number;
@override
String toString() => "type=$type number=$number";
}
class Emoticons extends StatefulWidget {
@ -239,9 +368,9 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
late CurvedAnimation curvedAnimation;
bool showIn = false;
MicRes? playingRes;
_SeatEmojiEvent? playingEvent;
List<MicRes?> pathList = [];
List<_SeatEmojiEvent> pathList = [];
String? giftPath;
List<String> giftList = [];
bool showResult = false;
@ -267,23 +396,31 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return Consumer<RtcProvider>(
builder: (BuildContext context, RtcProvider provider, Widget? child) {
MicRes? micRes = provider.roomWheatMap[widget.index];
if (micRes?.user == null) {
return Selector<RtcProvider, _SeatEmojiSnapshot>(
selector:
(context, provider) =>
_SeatEmojiSnapshot.fromMic(provider.roomWheatMap[widget.index]),
builder: (
BuildContext context,
_SeatEmojiSnapshot snapshot,
Widget? child,
) {
if (!snapshot.hasUser) {
return Container();
}
String? emojiPath = provider.roomWheatMap[widget.index]?.emojiPath;
if (emojiPath != null) {
pathList.add(micRes);
micRes?.setEmojiPath = null;
if (snapshot.emojiPath != null) {
pathList.add(
_SeatEmojiEvent(type: snapshot.type, number: snapshot.number),
);
context.read<RtcProvider>().roomWheatMap[widget.index]?.setEmojiPath =
null;
_checkStart();
}
if (playingRes != null) {
if (playingEvent != null) {
return FadeTransition(
opacity: curvedAnimation,
child:
playingRes?.type == SCRoomMsgType.roomDice
playingEvent?.type == SCRoomMsgType.roomDice
? FutureBuilder<void>(
future: Future.delayed(Duration(milliseconds: 2000)),
// Future
@ -294,7 +431,7 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
// snapshot的状态来构建UI
if (snapshot.connectionState == ConnectionState.done) {
return Image.asset(
"sc_images/room/sc_icon_dice_${micRes?.number}.png",
"sc_images/room/sc_icon_dice_${playingEvent?.number}.png",
height: widget.isGameModel ? 38.w : 45.w,
);
} else {
@ -305,7 +442,7 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
}
},
)
: (playingRes?.type == SCRoomMsgType.roomRPS
: (playingEvent?.type == SCRoomMsgType.roomRPS
? FutureBuilder<void>(
future: Future.delayed(Duration(milliseconds: 2000)),
// Future
@ -317,7 +454,7 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
if (snapshot.connectionState ==
ConnectionState.done) {
return Image.asset(
"sc_images/room/sc_icon_rps_${micRes?.number}.png",
"sc_images/room/sc_icon_rps_${playingEvent?.number}.png",
height: widget.isGameModel ? 38.w : 45.w,
);
} else {
@ -329,7 +466,7 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
},
)
: netImage(
url: playingRes?.number ?? "",
url: playingEvent?.number ?? "",
width: widget.isGameModel ? 65.w : 75.w,
)),
);
@ -349,13 +486,10 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
}
void _checkStart() {
print('emoticonsAniCtr.status:${emoticonsAniCtr.status}');
print('emoticonsAniCtr.path:${playingRes}');
if (!showIn) {
if (pathList.isNotEmpty) {
playingRes = pathList.first;
playingEvent = pathList.first;
pathList.removeAt(0);
print('播放表情:$playingRes');
emoticonsAniCtr.forward();
Future.delayed(Duration(milliseconds: 5)).then((value) {
setState(() {
@ -367,13 +501,12 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
Future.delayed(Duration(seconds: 3)).then((value) {
emoticonsAniCtr.reverse();
showIn = false;
playingRes = null;
playingEvent = null;
_checkStart();
});
} else if (giftList.isNotEmpty) {
giftPath = giftList.first;
giftList.removeAt(0);
print('播放礼物:$playingRes');
emoticonsAniCtr.forward();
Future.delayed(Duration(milliseconds: 10)).then((value) {
setState(() {
@ -396,12 +529,12 @@ class _EmoticonsState extends State<Emoticons> with TickerProviderStateMixin {
class Sonic extends StatefulWidget {
final num index;
final bool isGameModel;
final JoinRoomRes? room;
final String specialMikeType;
const Sonic({
Key? key,
required this.index,
required this.room,
required this.specialMikeType,
this.isGameModel = false,
}) : super(key: key);
@ -450,23 +583,33 @@ class _SonicState extends State<Sonic> with TickerProviderStateMixin {
return Stack(
clipBehavior: Clip.none,
children: [
SonicItem(ctrList[0], widget.room, isGameModel: widget.isGameModel),
SonicItem(ctrList[1], widget.room, isGameModel: widget.isGameModel),
SonicItem(ctrList[2], widget.room, isGameModel: widget.isGameModel),
SonicItem(ctrList[3], widget.room, isGameModel: widget.isGameModel),
SonicItem(
ctrList[0],
widget.specialMikeType,
isGameModel: widget.isGameModel,
),
SonicItem(
ctrList[1],
widget.specialMikeType,
isGameModel: widget.isGameModel,
),
SonicItem(
ctrList[2],
widget.specialMikeType,
isGameModel: widget.isGameModel,
),
SonicItem(
ctrList[3],
widget.specialMikeType,
isGameModel: widget.isGameModel,
),
],
);
}
void _checkSoundAni(int volume) async {
if (volume <= 20) return;
// await Future.delayed(Duration(milliseconds: 10));
// var dateTime = DateTime.now();
// for(int i =0 ; i < 5000000;i++){
// lastIndex = 1;
// }
// print('执行耗时:${DateTime.now().millisecondsSinceEpoch - dateTime.millisecondsSinceEpoch}');
ctrList.forEach((element) {
for (final element in ctrList) {
if (element.value == 0) {
if (lastIndex == 0) {
element.forward();
@ -481,11 +624,10 @@ class _SonicState extends State<Sonic> with TickerProviderStateMixin {
}
}
}
});
}
}
_onVoiceChange(num index, int volum) {
print('说话声音:$volum');
if (widget.index == index) {
_checkSoundAni(volum);
}
@ -494,11 +636,15 @@ class _SonicState extends State<Sonic> with TickerProviderStateMixin {
class SonicItem extends AnimatedWidget {
final Animation<double> animation;
final JoinRoomRes? room;
final String specialMikeType;
final bool isGameModel;
SonicItem(this.animation, this.room, {super.key, this.isGameModel = false})
: super(listenable: animation);
SonicItem(
this.animation,
this.specialMikeType, {
super.key,
this.isGameModel = false,
}) : super(listenable: animation);
@override
Widget build(BuildContext context) {
@ -510,15 +656,14 @@ class SonicItem extends AnimatedWidget {
height: width(isGameModel ? 34 : 52),
width: width(isGameModel ? 34 : 52),
decoration:
(room?.roomProfile?.roomSetting?.roomSpecialMikeType ?? "")
.isEmpty ||
(room?.roomProfile?.roomSetting?.roomSpecialMikeType !=
specialMikeType.isEmpty ||
(specialMikeType !=
SCRoomSpecialMikeType.TYPE_VIP3.name &&
room?.roomProfile?.roomSetting?.roomSpecialMikeType !=
specialMikeType !=
SCRoomSpecialMikeType.TYPE_VIP4.name &&
room?.roomProfile?.roomSetting?.roomSpecialMikeType !=
specialMikeType !=
SCRoomSpecialMikeType.TYPE_VIP5.name &&
room?.roomProfile?.roomSetting?.roomSpecialMikeType !=
specialMikeType !=
SCRoomSpecialMikeType.TYPE_VIP6.name)
? BoxDecoration(
shape: BoxShape.circle,
@ -528,25 +673,19 @@ class SonicItem extends AnimatedWidget {
)
: BoxDecoration(),
child:
room?.roomProfile?.roomSetting?.roomSpecialMikeType ==
SCRoomSpecialMikeType.TYPE_VIP3.name
specialMikeType == SCRoomSpecialMikeType.TYPE_VIP3.name
? Image.asset(
"sc_images/room/sc_icon_room_vip3_sonic_anim.webp",
)
: (room?.roomProfile?.roomSetting?.roomSpecialMikeType ==
SCRoomSpecialMikeType.TYPE_VIP4.name
: (specialMikeType == SCRoomSpecialMikeType.TYPE_VIP4.name
? Image.asset(
"sc_images/room/sc_icon_room_vip4_sonic_anim.webp",
)
: (room?.roomProfile?.roomSetting?.roomSpecialMikeType ==
SCRoomSpecialMikeType.TYPE_VIP5.name
: (specialMikeType == SCRoomSpecialMikeType.TYPE_VIP5.name
? Image.asset(
"sc_images/room/sc_icon_room_vip5_sonic_anim.webp",
)
: (room
?.roomProfile
?.roomSetting
?.roomSpecialMikeType ==
: (specialMikeType ==
SCRoomSpecialMikeType.TYPE_VIP6.name
? Image.asset(
"sc_images/room/sc_icon_room_vip6_sonic_anim.webp",

View File

@ -141,17 +141,16 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
SCGiftVapSvgaManager().stopPlayback();
}
bool roomThemeBackActi(JoinRoomRes? room) {
if (room?.roomProps?.roomTheme != null) {
if (room?.roomProps?.roomTheme?.themeBack != null &&
room!.roomProps!.roomTheme!.themeBack!.isNotEmpty) {
if ((room.roomProps?.roomTheme?.expireTime ?? 0) >
DateTime.now().millisecondsSinceEpoch) {
return true;
String? _resolveRoomThemeBackground(JoinRoomRes? room) {
final roomTheme = room?.roomProps?.roomTheme;
final themeBack = roomTheme?.themeBack ?? "";
if (themeBack.isEmpty) {
return null;
}
if ((roomTheme?.expireTime ?? 0) <= DateTime.now().millisecondsSinceEpoch) {
return null;
}
}
return false;
return themeBack;
}
@override
@ -173,13 +172,14 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
top: false,
child: Stack(
children: [
Consumer<RtcProvider>(
builder: (context, ref, child) {
return roomThemeBackActi(ref.currenRoom)
Selector<RtcProvider, String?>(
selector:
(context, provider) =>
_resolveRoomThemeBackground(provider.currenRoom),
builder: (context, roomThemeBackground, child) {
return roomThemeBackground != null
? netImage(
url:
ref.currenRoom?.roomProps?.roomTheme?.themeBack ??
"",
url: roomThemeBackground,
width: ScreenUtil().screenWidth,
height: ScreenUtil().screenHeight,
noDefaultImg: true,
@ -395,6 +395,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
giftModel.sendUserPic = msg.user?.userAvatar ?? "";
giftModel.giftPic = msg.gift?.giftPhoto ?? "";
giftModel.giftCount = 0;
giftModel.rewardAmount = awardAmount;
giftModel.showLuckyRewardFrame = true;
giftModel.rewardAmountText = _formatLuckyRewardAmount(awardAmount);
Provider.of<GiftAnimationManager>(

View File

@ -133,6 +133,16 @@ class BaishunJsBridge {
}
}));
}
function maskText(value) {
const text = (value == null ? '' : String(value)).trim();
if (!text) {
return '';
}
if (text.length <= 10) {
return text;
}
return text.slice(0, 6) + '***' + text.slice(-4);
}
function ensureChannelAlias(name, handler) {
if (!window[name] || typeof window[name].postMessage !== 'function') {
window[name] = { postMessage: handler };
@ -146,19 +156,27 @@ class BaishunJsBridge {
}
}
window.NativeBridge.getConfigSync = function() {
postDebug('bridge.getConfigSync', {
hasConfig: !!window.__baishunLastConfig,
hasNativeBridgeConfig: !!(window.NativeBridge && window.NativeBridge.config)
});
return window.__baishunLastConfig || null;
};
window.NativeBridge.getConfig = function(params) {
postDebug('bridge.getConfig.call', toPayload(params));
postAction('${BaishunBridgeActions.getConfig}', params);
return window.__baishunLastConfig || null;
};
window.NativeBridge.destroy = function(payload) {
postDebug('bridge.destroy.call', toPayload(payload));
postAction('${BaishunBridgeActions.destroy}', payload);
};
window.NativeBridge.gameRecharge = function(payload) {
postDebug('bridge.gameRecharge.call', toPayload(payload));
postAction('${BaishunBridgeActions.gameRecharge}', payload);
};
window.NativeBridge.gameLoaded = function(payload) {
postDebug('bridge.gameLoaded.call', toPayload(payload));
postAction('${BaishunBridgeActions.gameLoaded}', payload);
};
window.__baishunDebugLog = postDebug;
@ -359,6 +377,10 @@ class BaishunJsBridge {
if (typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
window.dispatchEvent(new CustomEvent('baishunBridgeReady'));
}
postDebug('bridge.ready', {
href: window.location && window.location.href,
ua: navigator && navigator.userAgent
});
})();
''';
}
@ -373,6 +395,16 @@ class BaishunJsBridge {
(function() {
const config = $payload;
const explicitCallbackPath = $encodedCallbackPath;
function maskText(value) {
const text = (value == null ? '' : String(value)).trim();
if (!text) {
return '';
}
if (text.length <= 10) {
return text;
}
return text.slice(0, 6) + '***' + text.slice(-4);
}
function resolvePath(path) {
if (!path || typeof path !== 'string') {
return null;
@ -407,15 +439,39 @@ class BaishunJsBridge {
window.baishunBridgeConfig = config;
window.NativeBridge = window.NativeBridge || {};
window.NativeBridge.config = config;
const callbackPath = explicitCallbackPath || window.__baishunLastJsCallback || '';
let callbackInvoked = false;
if (explicitCallbackPath) {
window.__baishunLastJsCallback = explicitCallbackPath;
}
if (typeof window.__baishunDebugLog === 'function') {
window.__baishunDebugLog('config.deliver', {
appId: config.appId,
appChannel: config.appChannel,
userId: config.userId,
roomId: config.roomId,
gameMode: config.gameMode,
language: config.language,
gsp: config.gsp,
code: maskText(config.code),
codeLength: config.code ? String(config.code).length : 0,
callbackPath: callbackPath
});
}
if (typeof window.__baishunGetConfigCallback === 'function') {
window.__baishunGetConfigCallback(config);
window.__baishunGetConfigCallback = null;
}
if (window.__baishunLastJsCallback) {
invokeCallbackPath(window.__baishunLastJsCallback);
callbackInvoked = invokeCallbackPath(window.__baishunLastJsCallback) || callbackInvoked;
}
if (typeof window.__baishunDebugLog === 'function') {
window.__baishunDebugLog('config.callback', {
callbackPath: callbackPath,
callbackInvoked: callbackInvoked,
hasLegacyCallback: typeof window.__baishunGetConfigCallback === 'function',
hasOnBaishunConfig: typeof window.onBaishunConfig === 'function'
});
}
if (typeof window.onBaishunConfig === 'function') {
window.onBaishunConfig(config);
@ -438,6 +494,9 @@ class BaishunJsBridge {
return '''
(function() {
const payload = $safePayload;
if (typeof window.__baishunDebugLog === 'function') {
window.__baishunDebugLog('wallet.update', payload);
}
if (window.baishunChannel && typeof window.baishunChannel.walletUpdate === 'function') {
window.baishunChannel.walletUpdate(payload);
}

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
@ -31,6 +32,8 @@ class BaishunGamePage extends StatefulWidget {
}
class _BaishunGamePageState extends State<BaishunGamePage> {
static const String _logPrefix = '[BaishunGame]';
final RoomGameRepository _repository = RoomGameRepository();
final Set<Factory<OneSequenceGestureRecognizer>> _webGestureRecognizers =
<Factory<OneSequenceGestureRecognizer>>{
@ -45,6 +48,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
bool _didReceiveBridgeMessage = false;
bool _didFinishPageLoad = false;
bool _hasDeliveredLaunchConfig = false;
int _bridgeInjectCount = 0;
String? _errorMessage;
@override
@ -92,15 +96,21 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String _) {
onPageStarted: (String url) {
_log('page_started url=${_clip(url, 240)}');
_prepareForPageLoad();
},
onPageFinished: (String _) async {
onPageFinished: (String url) async {
_didFinishPageLoad = true;
_log('page_finished url=${_clip(url, 240)}');
await _injectBridge(reason: 'page_finished');
},
onWebResourceError: (WebResourceError error) {
_stopBridgeBootstrap();
_log(
'web_resource_error code=${error.errorCode} '
'type=${error.errorType} desc=${_clip(error.description, 300)}',
);
_stopBridgeBootstrap(reason: 'web_resource_error');
if (!mounted) {
return;
}
@ -111,36 +121,56 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
},
),
);
_log('init launch=${_stringifyForLog(_buildLaunchSummary())}');
unawaited(_loadGameEntry());
}
@override
void dispose() {
_stopBridgeBootstrap();
_log('dispose isClosing=$_isClosing');
_stopBridgeBootstrap(reason: 'dispose');
super.dispose();
}
void _prepareForPageLoad() {
_log(
'prepare_page_load session=${widget.launchModel.gameSessionId} '
'entry=${_clip(widget.launchModel.entry.entryUrl, 240)}',
);
_didReceiveBridgeMessage = false;
_didFinishPageLoad = false;
_hasDeliveredLaunchConfig = false;
_stopBridgeBootstrap();
_bridgeInjectCount = 0;
_stopBridgeBootstrap(reason: 'prepare_page_load');
_bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), (
Timer timer,
) {
if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) {
_log(
'bootstrap_timer_stop tick=${timer.tick} '
'didReceiveBridge=$_didReceiveBridgeMessage '
'hasError=${_errorMessage != null}',
);
timer.cancel();
return;
}
unawaited(_injectBridge(reason: 'bootstrap'));
if (_didFinishPageLoad && timer.tick >= 12) {
_log(
'bootstrap_timer_stop tick=${timer.tick} reason=page_finished_guard',
);
timer.cancel();
}
});
_loadingFallbackTimer = Timer(const Duration(seconds: 6), () {
if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) {
_log(
'loading_fallback_skip didReceiveBridge=$_didReceiveBridgeMessage '
'hasError=${_errorMessage != null}',
);
return;
}
_log('loading_fallback_fire isLoading=$_isLoading');
setState(() {
_isLoading = false;
});
@ -154,7 +184,10 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
});
}
void _stopBridgeBootstrap() {
void _stopBridgeBootstrap({String reason = 'manual'}) {
if (_bridgeBootstrapTimer != null || _loadingFallbackTimer != null) {
_log('stop_bootstrap reason=$reason');
}
_bridgeBootstrapTimer?.cancel();
_bridgeBootstrapTimer = null;
_loadingFallbackTimer?.cancel();
@ -165,10 +198,15 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
try {
_prepareForPageLoad();
final entryUrl = widget.launchModel.entry.entryUrl.trim();
_log(
'load_entry launchMode=${widget.launchModel.entry.launchMode} '
'entryUrl=${_clip(entryUrl, 240)}',
);
if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) {
final html = await rootBundle.loadString(
'assets/debug/baishun_mock/index.html',
);
_log('load_mock_html baseUrl=https://baishun.mock.local/');
await _controller.loadHtmlString(
html,
baseUrl: 'https://baishun.mock.local/',
@ -180,8 +218,10 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
if (uri == null) {
throw Exception('Invalid game entry url: $entryUrl');
}
_log('load_request uri=${_clip(uri.toString(), 240)}');
await _controller.loadRequest(uri);
} catch (error) {
_log('load_entry_error error=${_clip(error.toString(), 400)}');
if (!mounted) {
return;
}
@ -193,9 +233,19 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
}
Future<void> _injectBridge({String reason = 'manual'}) async {
_bridgeInjectCount += 1;
if (reason != 'bootstrap' ||
_bridgeInjectCount <= 3 ||
_bridgeInjectCount % 5 == 0) {
_log('inject_bridge reason=$reason count=$_bridgeInjectCount');
}
try {
await _controller.runJavaScript(BaishunJsBridge.bootstrapScript());
} catch (_) {}
} catch (error) {
_log(
'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}',
);
}
}
Future<void> _sendConfigToGame({
@ -203,11 +253,16 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
bool force = false,
}) async {
if (_hasDeliveredLaunchConfig && !force) {
_log('skip_send_config reason=already_delivered');
return;
}
_hasDeliveredLaunchConfig = true;
final rawConfig = widget.launchModel.bridgeConfig;
final config = _buildEffectiveBridgeConfig(rawConfig);
_log(
'send_config jsCallback=${jsCallbackPath ?? ''} '
'force=$force config=${_stringifyForLog(_buildConfigSummary(config, rawConfig: rawConfig))}',
);
try {
await _controller.runJavaScript(
BaishunJsBridge.buildConfigScript(
@ -215,10 +270,13 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
jsCallbackPath: jsCallbackPath,
),
);
} catch (_) {}
} catch (error) {
_log('send_config_error error=${_clip(error.toString(), 300)}');
}
}
Future<void> _handleBridgeMessage(JavaScriptMessage message) async {
_log('channel_message raw=${_clip(message.message, 600)}');
final bridgeMessage = BaishunBridgeMessage.parse(message.message);
await _dispatchBridgeMessage(bridgeMessage);
}
@ -231,6 +289,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
action,
message.message,
);
_log('named_channel action=$action raw=${_clip(message.message, 600)}');
await _dispatchBridgeMessage(bridgeMessage);
}
@ -238,12 +297,20 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
BaishunBridgeMessage bridgeMessage,
) async {
if (bridgeMessage.action == BaishunBridgeActions.debugLog) {
final tag = bridgeMessage.payload['tag']?.toString().trim();
final message = bridgeMessage.payload['message']?.toString() ?? '';
_log('h5_debug tag=${tag ?? 'unknown'} message=${_clip(message, 800)}');
return;
}
final callbackPath = _extractCallbackPath(bridgeMessage.payload);
_log(
'bridge_action action=${bridgeMessage.action} '
'callback=${callbackPath.isEmpty ? '-' : callbackPath} '
'payload=${_stringifyForLog(_sanitizeForLog(bridgeMessage.payload))}',
);
if (bridgeMessage.action.isNotEmpty) {
_didReceiveBridgeMessage = true;
_stopBridgeBootstrap();
_stopBridgeBootstrap(reason: 'bridge_message_${bridgeMessage.action}');
}
switch (bridgeMessage.action) {
case BaishunBridgeActions.getConfig:
@ -255,35 +322,43 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
if (!mounted) {
return;
}
_log('game_loaded received');
setState(() {
_isLoading = false;
_errorMessage = null;
});
break;
case BaishunBridgeActions.gameRecharge:
_log('game_recharge open_wallet');
await SCNavigatorUtils.push(context, WalletRoute.recharge);
await _notifyWalletUpdate();
break;
case BaishunBridgeActions.destroy:
_log('destroy requested by h5');
await _closeAndExit(reason: 'h5_destroy');
break;
default:
_log('bridge_action_unhandled action=${bridgeMessage.action}');
break;
}
}
Future<void> _notifyWalletUpdate() async {
_log('notify_wallet_update');
try {
await _controller.runJavaScript(
BaishunJsBridge.buildWalletUpdateScript(),
);
} catch (_) {}
} catch (error) {
_log('notify_wallet_update_error error=${_clip(error.toString(), 300)}');
}
}
Future<void> _reload() async {
if (!mounted) {
return;
}
_log('reload');
setState(() {
_errorMessage = null;
_isLoading = true;
@ -293,19 +368,27 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
Future<void> _closeAndExit({String reason = 'page_back'}) async {
if (_isClosing) {
_log('close_skip reason=already_closing source=$reason');
return;
}
_isClosing = true;
_log('close_start reason=$reason');
try {
if (!widget.launchModel.gameSessionId.startsWith('bs_mock_')) {
await _repository.closeBaishunGame(
final result = await _repository.closeBaishunGame(
roomId: widget.roomId,
gameSessionId: widget.launchModel.gameSessionId,
reason: reason,
);
_log(
'close_success result=${_stringifyForLog(<String, dynamic>{'roomId': result.roomId, 'state': result.state, 'gameSessionId': result.gameSessionId, 'currentGameId': result.currentGameId, 'hostUserId': result.hostUserId})}',
);
}
} catch (error) {
_log('close_error error=${_clip(error.toString(), 300)}');
}
} catch (_) {}
if (mounted) {
_log('close_pop');
Navigator.of(context).pop();
}
}
@ -324,6 +407,110 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
return '';
}
void _log(String message) {
debugPrint('$_logPrefix $message');
}
String _clip(String value, [int limit = 600]) {
final trimmed = value.trim();
if (trimmed.length <= limit) {
return trimmed;
}
return '${trimmed.substring(0, limit)}...';
}
String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return '';
}
if (trimmed.length <= keepStart + keepEnd) {
return trimmed;
}
return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}';
}
Object? _sanitizeForLog(Object? value, {String key = ''}) {
final lowerKey = key.toLowerCase();
final shouldMask =
lowerKey.contains('code') ||
lowerKey.contains('token') ||
lowerKey.contains('authorization');
if (value is Map) {
final result = <String, dynamic>{};
for (final MapEntry<dynamic, dynamic> entry in value.entries) {
result[entry.key.toString()] = _sanitizeForLog(
entry.value,
key: entry.key.toString(),
);
}
return result;
}
if (value is List) {
return value.map((Object? item) => _sanitizeForLog(item)).toList();
}
if (value is String) {
final normalized = shouldMask ? _maskValue(value) : value;
return _clip(normalized, 600);
}
return value;
}
String _stringifyForLog(Object? value) {
if (value == null) {
return '';
}
try {
return _clip(jsonEncode(value), 800);
} catch (_) {
return _clip(value.toString(), 800);
}
}
Map<String, dynamic> _buildLaunchSummary() {
final entry = widget.launchModel.entry;
final roomState = widget.launchModel.roomState;
return <String, dynamic>{
'roomId': widget.roomId,
'gameId': widget.game.gameId,
'gameName': widget.game.name,
'vendorType': widget.launchModel.vendorType,
'vendorGameId': widget.launchModel.vendorGameId,
'gameSessionId': widget.launchModel.gameSessionId,
'launchMode': entry.launchMode,
'entryUrl': _clip(entry.entryUrl, 240),
'previewUrl': _clip(entry.previewUrl, 240),
'packageVersion': entry.packageVersion,
'orientation': entry.orientation,
'safeHeight': entry.safeHeight,
'roomState': roomState.state,
'currentGameId': roomState.currentGameId,
'hostUserId': roomState.hostUserId,
'bridgeConfig': _buildConfigSummary(widget.launchModel.bridgeConfig),
};
}
Map<String, dynamic> _buildConfigSummary(
BaishunBridgeConfigModel config, {
BaishunBridgeConfigModel? rawConfig,
}) {
return <String, dynamic>{
'appName': config.appName,
'appChannel': config.appChannel,
'appId': config.appId,
'userId': config.userId,
'roomId': config.roomId,
'gameMode': config.gameMode,
'language': config.language,
'rawLanguage': rawConfig?.language ?? config.language,
'gsp': config.gsp,
'code': _maskValue(config.code),
'codeLength': config.code.length,
'sceneMode': config.gameConfig.sceneMode,
'currencyIcon': config.gameConfig.currencyIcon,
};
}
BaishunBridgeConfigModel _buildEffectiveBridgeConfig(
BaishunBridgeConfigModel rawConfig,
) {

View File

@ -22,6 +22,7 @@ class RoomGameListSheet extends StatefulWidget {
}
class _RoomGameListSheetState extends State<RoomGameListSheet> {
static const String _logPrefix = '[BaishunLaunch]';
static const int _itemsPerRow = 4;
static const String _sheetFrameAsset =
'sc_images/room/sc_room_game_sheet_frame.png';
@ -78,11 +79,31 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
});
try {
_log(
'launch_request roomId=$_roomId gameId=${game.gameId} '
'vendorGameId=${game.vendorGameId} gameMode=${game.gameMode} '
'origin=${Platform.isAndroid ? 'ANDROID' : 'IOS'}',
);
final launchModel = await _repository.launchBaishunGame(
roomId: _roomId,
gameId: game.gameId,
clientOrigin: Platform.isAndroid ? 'ANDROID' : 'IOS',
);
_log(
'launch_success payload=${_stringifyForLog(<String, dynamic>{
'gameSessionId': launchModel.gameSessionId,
'vendorType': launchModel.vendorType,
'gameId': launchModel.gameId,
'vendorGameId': launchModel.vendorGameId,
'entryUrl': _clip(launchModel.entry.entryUrl, 240),
'launchMode': launchModel.entry.launchMode,
'packageVersion': launchModel.entry.packageVersion,
'orientation': launchModel.entry.orientation,
'safeHeight': launchModel.entry.safeHeight,
'roomState': launchModel.roomState.state,
'bridgeConfig': <String, dynamic>{'userId': launchModel.bridgeConfig.userId, 'roomId': launchModel.bridgeConfig.roomId, 'gameMode': launchModel.bridgeConfig.gameMode, 'language': launchModel.bridgeConfig.language, 'gsp': launchModel.bridgeConfig.gsp, 'code': _maskValue(launchModel.bridgeConfig.code), 'codeLength': launchModel.bridgeConfig.code.length},
})}',
);
if (!mounted) {
return;
}
@ -99,6 +120,7 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
barrierDismissible: true,
);
} catch (error) {
_log('launch_error error=${_clip(error.toString(), 400)}');
SCTts.show('Launch failed');
} finally {
if (mounted) {
@ -109,6 +131,36 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
}
}
void _log(String message) {
debugPrint('$_logPrefix $message');
}
String _clip(String value, [int limit = 600]) {
final trimmed = value.trim();
if (trimmed.length <= limit) {
return trimmed;
}
return '${trimmed.substring(0, limit)}...';
}
String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return '';
}
if (trimmed.length <= keepStart + keepEnd) {
return trimmed;
}
return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}';
}
String _stringifyForLog(Object? value) {
if (value == null) {
return '';
}
return _clip(value.toString(), 800);
}
@override
Widget build(BuildContext context) {
return SafeArea(

View File

@ -48,6 +48,10 @@ typedef RtcProvider = RealTimeCommunicationManager;
class RealTimeCommunicationManager extends ChangeNotifier {
static const Duration _micListPollingInterval = Duration(seconds: 2);
static const Duration _onlineUsersPollingInterval = Duration(seconds: 3);
static const Duration _selfMicSwitchGracePeriod = Duration(seconds: 4);
static const Duration _giftTriggeredMicRefreshMinInterval = Duration(
milliseconds: 900,
);
bool needUpDataUserInfo = false;
bool _roomVisualEffectsEnabled = false;
@ -56,6 +60,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Timer? _onlineUsersPollingTimer;
bool _isRefreshingMicList = false;
bool _isRefreshingOnlineUsers = false;
bool _pendingMicListRefresh = false;
Timer? _deferredMicListRefreshTimer;
int _lastMicListRefreshStartedAtMs = 0;
num? _preferredSelfMicIndex;
num? _pendingSelfMicSourceIndex;
int? _pendingSelfMicSwitchGuardUntilMs;
ClientRoleType? _lastAppliedClientRole;
bool? _lastAppliedLocalAudioMuted;
bool? _lastScheduledVoiceLiveOnMic;
String? _lastScheduledVoiceLiveRoomId;
String? _lastScheduledAnchorRoomId;
///
JoinRoomRes? currenRoom;
@ -141,8 +156,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _stopRoomStatePolling() {
_micListPollingTimer?.cancel();
_onlineUsersPollingTimer?.cancel();
_deferredMicListRefreshTimer?.cancel();
_micListPollingTimer = null;
_onlineUsersPollingTimer = null;
_deferredMicListRefreshTimer = null;
_isRefreshingMicList = false;
_isRefreshingOnlineUsers = false;
}
@ -209,14 +226,13 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void _refreshManagerUsers(List<SocialChatUserProfile> users) {
managerUsers
..clear()
..addAll(
users.where((user) => user.roles == SCRoomRolesType.ADMIN.name),
);
..addAll(users.where((user) => user.roles == SCRoomRolesType.ADMIN.name));
}
Map<num, MicRes> _buildMicMap(List<MicRes> roomWheatList) {
final previousMap = roomWheatMap;
final nextMap = <num, MicRes>{};
final userSeatMap = <String, num>{};
for (final roomWheat in roomWheatList) {
final micIndex = roomWheat.micIndex;
if (micIndex == null) {
@ -226,7 +242,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
final shouldPreserveTransientState =
previousMic?.user?.id == roomWheat.user?.id &&
previousMic?.user?.id != null;
nextMap[micIndex] =
final mergedMic =
shouldPreserveTransientState
? roomWheat.copyWith(
emojiPath:
@ -243,15 +259,173 @@ class RealTimeCommunicationManager extends ChangeNotifier {
: previousMic?.number,
)
: roomWheat;
final normalizedUserId = (mergedMic.user?.id ?? "").trim();
if (normalizedUserId.isNotEmpty) {
final existingIndex = userSeatMap[normalizedUserId];
if (existingIndex != null) {
final shouldReplaceExistingSeat = _shouldPreferDuplicateMicSeat(
userId: normalizedUserId,
existingIndex: existingIndex,
candidateIndex: micIndex,
previousMap: previousMap,
);
if (!shouldReplaceExistingSeat) {
continue;
}
nextMap.remove(existingIndex);
}
userSeatMap[normalizedUserId] = micIndex;
}
nextMap[micIndex] = mergedMic;
}
return _stabilizeSelfMicSnapshot(nextMap, previousMap: previousMap);
}
void _startSelfMicSwitchGuard({
required num sourceIndex,
required num targetIndex,
}) {
_preferredSelfMicIndex = targetIndex;
_pendingSelfMicSourceIndex = sourceIndex;
_pendingSelfMicSwitchGuardUntilMs =
DateTime.now().add(_selfMicSwitchGracePeriod).millisecondsSinceEpoch;
}
void _clearSelfMicSwitchGuard({bool clearPreferredIndex = false}) {
_pendingSelfMicSourceIndex = null;
_pendingSelfMicSwitchGuardUntilMs = null;
if (clearPreferredIndex) {
_preferredSelfMicIndex = null;
}
}
Map<num, MicRes> _stabilizeSelfMicSnapshot(
Map<num, MicRes> nextMap, {
required Map<num, MicRes> previousMap,
}) {
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (currentUserId.isEmpty) {
return nextMap;
}
final targetIndex = _preferredSelfMicIndex;
final sourceIndex = _pendingSelfMicSourceIndex;
final guardUntilMs = _pendingSelfMicSwitchGuardUntilMs ?? 0;
final hasActiveGuard =
targetIndex != null &&
sourceIndex != null &&
guardUntilMs > DateTime.now().millisecondsSinceEpoch;
final selfSeatIndices =
nextMap.entries
.where((entry) => entry.value.user?.id == currentUserId)
.map((entry) => entry.key)
.toList();
if (targetIndex != null &&
nextMap[targetIndex]?.user?.id == currentUserId) {
_clearSelfMicSwitchGuard();
return nextMap;
}
if (!hasActiveGuard) {
_clearSelfMicSwitchGuard();
return nextMap;
}
final resolvedTargetIndex = targetIndex;
final resolvedSourceIndex = sourceIndex;
final selfOnlyOnSource =
selfSeatIndices.isEmpty ||
selfSeatIndices.every((seatIndex) => seatIndex == resolvedSourceIndex);
if (!selfOnlyOnSource) {
return nextMap;
}
final optimisticTargetSeat = previousMap[resolvedTargetIndex];
if (optimisticTargetSeat == null ||
optimisticTargetSeat.user?.id != currentUserId) {
return nextMap;
}
final incomingTargetSeat = nextMap[resolvedTargetIndex];
if (incomingTargetSeat?.user != null &&
incomingTargetSeat?.user?.id != currentUserId) {
return nextMap;
}
final stabilizedMap = Map<num, MicRes>.from(nextMap);
for (final seatIndex in selfSeatIndices) {
if (seatIndex == resolvedTargetIndex) {
continue;
}
final seat = stabilizedMap[seatIndex];
if (seat == null) {
continue;
}
stabilizedMap[seatIndex] = seat.copyWith(clearUser: true);
}
final baseSeat = incomingTargetSeat ?? optimisticTargetSeat;
stabilizedMap[resolvedTargetIndex] = baseSeat.copyWith(
user: optimisticTargetSeat.user,
micMute: incomingTargetSeat?.micMute ?? optimisticTargetSeat.micMute,
micLock: incomingTargetSeat?.micLock ?? optimisticTargetSeat.micLock,
roomToken:
incomingTargetSeat?.roomToken ?? optimisticTargetSeat.roomToken,
emojiPath:
(incomingTargetSeat?.emojiPath ?? "").isNotEmpty
? incomingTargetSeat?.emojiPath
: optimisticTargetSeat.emojiPath,
type:
(incomingTargetSeat?.type ?? "").isNotEmpty
? incomingTargetSeat?.type
: optimisticTargetSeat.type,
number:
(incomingTargetSeat?.number ?? "").isNotEmpty
? incomingTargetSeat?.number
: optimisticTargetSeat.number,
);
return stabilizedMap;
}
bool _shouldPreferDuplicateMicSeat({
required String userId,
required num existingIndex,
required num candidateIndex,
required Map<num, MicRes> previousMap,
}) {
if (existingIndex == candidateIndex) {
return true;
}
final currentUserId =
(AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim();
if (userId == currentUserId && _preferredSelfMicIndex != null) {
if (candidateIndex == _preferredSelfMicIndex) {
return true;
}
if (existingIndex == _preferredSelfMicIndex) {
return false;
}
}
final candidateWasPreviouslyOccupiedByUser =
previousMap[candidateIndex]?.user?.id == userId;
final existingWasPreviouslyOccupiedByUser =
previousMap[existingIndex]?.user?.id == userId;
if (candidateWasPreviouslyOccupiedByUser !=
existingWasPreviouslyOccupiedByUser) {
return candidateWasPreviouslyOccupiedByUser;
}
return false;
}
void _syncSelfMicRuntimeState() {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
if ((currentUserId ?? "").isEmpty) {
return;
}
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
MicRes? currentUserMic;
for (final mic in roomWheatMap.values) {
@ -262,25 +436,110 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
if (currentUserMic == null) {
SCHeartbeatUtils.cancelAnchorTimer();
engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
engine?.muteLocalAudioStream(true);
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
_syncRoomHeartbeatState(
roomId: roomId,
isOnMic: false,
shouldRunAnchorHeartbeat: false,
);
_applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleAudience,
muted: true,
);
return;
}
SCHeartbeatUtils.scheduleAnchorHeartbeat(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
_preferredSelfMicIndex = currentUserMic.micIndex;
_syncRoomHeartbeatState(
roomId: roomId,
isOnMic: true,
shouldRunAnchorHeartbeat: true,
);
if ((currentUserMic.micMute ?? false) || isMic) {
engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
engine?.muteLocalAudioStream(true);
_applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleAudience,
muted: true,
);
return;
}
_applyLocalAudioRuntimeState(
clientRole: ClientRoleType.clientRoleBroadcaster,
muted: false,
);
}
void _syncRoomHeartbeatState({
required String roomId,
required bool isOnMic,
required bool shouldRunAnchorHeartbeat,
}) {
final normalizedRoomId = roomId.trim();
if (normalizedRoomId.isEmpty) {
_resetHeartbeatTracking();
return;
}
final voiceLiveChanged =
_lastScheduledVoiceLiveRoomId != normalizedRoomId ||
_lastScheduledVoiceLiveOnMic != isOnMic;
if (voiceLiveChanged) {
SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.VOICE_LIVE.name,
isOnMic,
roomId: normalizedRoomId,
);
_lastScheduledVoiceLiveRoomId = normalizedRoomId;
_lastScheduledVoiceLiveOnMic = isOnMic;
}
if (!shouldRunAnchorHeartbeat) {
if (_lastScheduledAnchorRoomId != null) {
SCHeartbeatUtils.cancelAnchorTimer();
_lastScheduledAnchorRoomId = null;
}
return;
}
if (voiceLiveChanged || _lastScheduledAnchorRoomId != normalizedRoomId) {
SCHeartbeatUtils.scheduleAnchorHeartbeat(normalizedRoomId);
_lastScheduledAnchorRoomId = normalizedRoomId;
}
}
void _applyLocalAudioRuntimeState({
required ClientRoleType clientRole,
required bool muted,
}) {
if (engine == null) {
_lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null;
return;
}
if (_lastAppliedClientRole != clientRole) {
engine?.setClientRole(role: clientRole);
_lastAppliedClientRole = clientRole;
}
if (!muted && _lastAppliedLocalAudioMuted != false) {
adjustRecordingSignalVolume(100);
engine?.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
engine?.muteLocalAudioStream(false);
}
if (_lastAppliedLocalAudioMuted != muted) {
engine?.muteLocalAudioStream(muted);
_lastAppliedLocalAudioMuted = muted;
}
}
void _resetHeartbeatTracking() {
_lastScheduledVoiceLiveOnMic = null;
_lastScheduledVoiceLiveRoomId = null;
_lastScheduledAnchorRoomId = null;
}
void _resetLocalAudioRuntimeTracking() {
_lastAppliedClientRole = null;
_lastAppliedLocalAudioMuted = null;
}
void _applyMicSnapshot(
@ -295,6 +554,90 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
}
int? _tryParseAgoraUid(String? value) {
final normalizedValue = (value ?? '').trim();
if (normalizedValue.isEmpty) {
return null;
}
return int.tryParse(normalizedValue);
}
int _stableAgoraUidFromUser(SocialChatUserProfile? user) {
final source =
'${user?.account ?? ""}|${user?.id ?? ""}|${user?.userNickname ?? ""}';
var hash = 0x811C9DC5;
for (final codeUnit in source.codeUnits) {
hash ^= codeUnit;
hash = (hash * 0x01000193) & 0x7fffffff;
}
return hash == 0 ? 1 : hash;
}
int _resolveAgoraUidForUser(SocialChatUserProfile? user) {
final parsedAccountUid = _tryParseAgoraUid(user?.account);
if (parsedAccountUid != null && parsedAccountUid > 0) {
return parsedAccountUid;
}
final parsedUserIdUid = _tryParseAgoraUid(user?.id);
if (parsedUserIdUid != null && parsedUserIdUid > 0) {
return parsedUserIdUid;
}
return _stableAgoraUidFromUser(user);
}
int _resolveAgoraUidForCurrentUser() {
return _resolveAgoraUidForUser(
AccountStorage().getCurrentUser()?.userProfile,
);
}
void requestGiftTriggeredMicRefresh() {
requestMicrophoneListRefresh(
notifyIfUnchanged: false,
minInterval: _giftTriggeredMicRefreshMinInterval,
);
}
void requestMicrophoneListRefresh({
bool notifyIfUnchanged = false,
Duration minInterval = Duration.zero,
}) {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty) {
return;
}
if (minInterval <= Duration.zero) {
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = null;
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
return;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final elapsedMs = nowMs - _lastMicListRefreshStartedAtMs;
if (!_isRefreshingMicList && elapsedMs >= minInterval.inMilliseconds) {
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = null;
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
return;
}
final remainingMs = minInterval.inMilliseconds - elapsedMs;
final delayMs = remainingMs > 80 ? remainingMs : 80;
_deferredMicListRefreshTimer?.cancel();
_deferredMicListRefreshTimer = Timer(Duration(milliseconds: delayMs), () {
_deferredMicListRefreshTimer = null;
retrieveMicrophoneList(
notifyIfUnchanged: notifyIfUnchanged,
).catchError((_) {});
});
}
Future<void> joinAgoraVoiceChannel() async {
try {
engine = await _initAgoraRtcEngine();
@ -364,9 +707,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
await engine?.joinChannel(
token: rtcToken.rtcToken ?? "",
channelId: currenRoom?.roomProfile?.roomProfile?.id ?? "",
uid: int.parse(
AccountStorage().getCurrentUser()?.userProfile?.account ?? "0",
),
uid: _resolveAgoraUidForCurrentUser(),
options: ChannelMediaOptions(
//
autoSubscribeVideo: false,
@ -381,6 +722,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
),
);
_syncSelfMicRuntimeState();
engine?.muteAllRemoteAudioStreams(roomIsMute);
} catch (e) {
SCTts.show("Join room fail");
@ -411,34 +753,40 @@ class RealTimeCommunicationManager extends ChangeNotifier {
// }
// print('初始化声音监听器:${user.toJson()}');
///
roomWheatMap.forEach((k, v) {
roomWheatMap[k]!.setVolume = 0;
});
for (var info in speakers) {
num? uid = info.uid;
int voloum = info.volume!;
print('${info.uid}在说话---${voloum}');
if (voloum > 0) {
///
if (uid == 0) {
uid = num.parse(
AccountStorage().getCurrentUser()!.userProfile!.account!,
);
if (roomWheatMap.isEmpty || speakers.isEmpty) {
return;
}
///
roomWheatMap.forEach((k, v) {
if (v.user != null) {
if (num.parse(v.user!.account!) == uid) {
roomWheatMap[k]!.setVolume = voloum;
for (var element in _onSoundVoiceChangeList) {
element(k, voloum);
}
for (final seat in roomWheatMap.values) {
seat.setVolume = 0;
}
final seatIndexByAgoraUid = <int, num>{};
roomWheatMap.forEach((seatIndex, mic) {
final user = mic.user;
if (user == null) {
return;
}
seatIndexByAgoraUid[_resolveAgoraUidForUser(user)] = seatIndex;
});
final currentUserAgoraUid = _resolveAgoraUidForCurrentUser();
final listeners = List<OnSoundVoiceChange>.from(_onSoundVoiceChangeList);
for (final info in speakers) {
final volume = info.volume ?? 0;
if (volume <= 0) {
continue;
}
final resolvedAgoraUid = info.uid == 0 ? currentUserAgoraUid : info.uid;
final seatIndex = seatIndexByAgoraUid[resolvedAgoraUid];
if (seatIndex == null) {
continue;
}
roomWheatMap[seatIndex]?.setVolume = volume;
for (final listener in listeners) {
listener(seatIndex, volume);
}
}
}
@ -731,33 +1079,32 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Future<void> retrieveMicrophoneList({bool notifyIfUnchanged = true}) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
if (roomId.isEmpty || _isRefreshingMicList) {
if (roomId.isEmpty) {
return;
}
if (_isRefreshingMicList) {
_pendingMicListRefresh = true;
return;
}
_isRefreshingMicList = true;
bool isOnMic = false;
_lastMicListRefreshStartedAtMs = DateTime.now().millisecondsSinceEpoch;
try {
final roomWheatList = await SCChatRoomRepository().micList(roomId);
if (roomId != currenRoom?.roomProfile?.roomProfile?.id) {
return;
}
final nextMap = _buildMicMap(roomWheatList);
for (final roomWheat in nextMap.values) {
if (roomWheat.user != null &&
roomWheat.user!.id ==
AccountStorage().getCurrentUser()?.userProfile?.id) {
isOnMic = true;
break;
}
}
_applyMicSnapshot(nextMap, notifyIfUnchanged: notifyIfUnchanged);
SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.VOICE_LIVE.name,
isOnMic,
roomId: currenRoom?.roomProfile?.roomProfile?.id,
);
} finally {
_isRefreshingMicList = false;
if (_pendingMicListRefresh) {
_pendingMicListRefresh = false;
unawaited(
Future<void>.microtask(
() => retrieveMicrophoneList(notifyIfUnchanged: notifyIfUnchanged),
),
);
}
}
}
@ -848,6 +1195,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
///
void _clearData() {
_stopRoomStatePolling();
engine = null;
_resetHeartbeatTracking();
_resetLocalAudioRuntimeTracking();
roomRocketStatus = null;
rtmProvider
?.onNewMessageListenerGroupMap["${currenRoom?.roomProfile?.roomProfile?.roomAccount}"] =
@ -857,6 +1207,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
managerUsers.clear();
needUpDataUserInfo = false;
SCRoomUtils.roomUsersMap.clear();
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
roomIsMute = false;
rtmProvider?.roomAllMsgList.clear();
rtmProvider?.roomChatMsgList.clear();
@ -979,6 +1330,19 @@ class RealTimeCommunicationManager extends ChangeNotifier {
retrieveMicrophoneList(notifyIfUnchanged: false).catchError((_) {});
}
void _clearUserFromSeats(String? userId, {num? exceptIndex}) {
final normalizedUserId = (userId ?? "").trim();
if (normalizedUserId.isEmpty) {
return;
}
roomWheatMap.forEach((seatIndex, seat) {
if (seat.user?.id != normalizedUserId || seatIndex == exceptIndex) {
return;
}
roomWheatMap[seatIndex] = seat.copyWith(clearUser: true);
});
}
void _openRoomUserInfoCard(String? userId) {
final normalizedUserId = (userId ?? '').trim();
if (normalizedUserId.isEmpty) {
@ -1035,18 +1399,44 @@ class RealTimeCommunicationManager extends ChangeNotifier {
SCHeartbeatUtils.scheduleAnchorHeartbeat(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
);
final currentSeat = roomWheatMap[index];
if (currentSeat != null && myUser != null) {
roomWheatMap[index] = currentSeat.copyWith(
user: myUser.copyWith(roles: currenRoom?.entrants?.roles),
final targetIndex = micGoUpRes.micIndex ?? index;
final previousSelfSeatIndex =
myUser != null ? userOnMaiInIndex(myUser.id ?? "") : -1;
final isSeatSwitching =
previousSelfSeatIndex > -1 && previousSelfSeatIndex != targetIndex;
if (myUser != null) {
if (isSeatSwitching) {
_startSelfMicSwitchGuard(
sourceIndex: previousSelfSeatIndex,
targetIndex: targetIndex,
);
} else {
_preferredSelfMicIndex = targetIndex;
_clearSelfMicSwitchGuard();
}
_clearUserFromSeats(myUser.id, exceptIndex: targetIndex);
final currentSeat = roomWheatMap[targetIndex];
final currentUser = myUser.copyWith(roles: currenRoom?.entrants?.roles);
roomWheatMap[targetIndex] =
currentSeat?.copyWith(
user: currentUser,
micMute: micGoUpRes.micMute,
micLock: micGoUpRes.micLock,
roomToken: micGoUpRes.roomToken,
) ??
MicRes(
roomId: currenRoom?.roomProfile?.roomProfile?.id,
micIndex: targetIndex,
micLock: micGoUpRes.micLock,
micMute: micGoUpRes.micMute,
user: currentUser,
roomToken: micGoUpRes.roomToken,
);
}
if (roomWheatMap[index]?.micMute ?? false) {
if (roomWheatMap[targetIndex]?.micMute ?? false) {
///
if (isFz()) {
await jieJinMai(index);
await jieJinMai(targetIndex);
}
}
@ -1069,15 +1459,13 @@ class RealTimeCommunicationManager extends ChangeNotifier {
isMic = true;
engine?.muteLocalAudioStream(true);
}
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
SCHeartbeatUtils.cancelAnchorTimer();
///
engine?.renewToken("");
engine?.setClientRole(role: ClientRoleType.clientRoleAudience);
final currentSeat = roomWheatMap[index];
if (currentSeat != null) {
roomWheatMap[index] = currentSeat.copyWith(user: null);
}
_clearUserFromSeats(AccountStorage().getCurrentUser()?.userProfile?.id);
notifyListeners();
_refreshMicListSilently();
} catch (ex) {
@ -1182,14 +1570,6 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return;
}
_applyMicSnapshot(_buildMicMap(mics));
final isOnMic =
userOnMaiInIndex(AccountStorage().getCurrentUser()?.userProfile?.id ?? "") >
-1;
SCHeartbeatUtils.scheduleHeartbeat(
SCHeartbeatStatus.VOICE_LIVE.name,
isOnMic,
roomId: currenRoom?.roomProfile?.roomProfile?.id,
);
}
/// -1

View File

@ -48,7 +48,8 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_config_reposito
import 'package:yumi/shared/data_sources/models/message/big_broadcast_group_message.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/shared/business_logic/models/res/sc_broad_cast_luck_gift_push.dart';
import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push.dart';
import 'package:yumi/shared/business_logic/models/res/broad_cast_mic_change_push.dart'
hide Data;
import 'package:yumi/shared/business_logic/models/res/gift_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_public_message_page_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_room_theme_list_res.dart';
@ -72,6 +73,9 @@ typedef RtmProvider = RealTimeMessagingManager;
class RealTimeMessagingManager extends ChangeNotifier {
static const int _giftComboMergeWindowMs = 3000;
static const int _maxLuckGiftPushQueueLength = 12;
static const int _luckyGiftFloatMinMultiple = 5;
static const int _luckyGiftBurstMinMultiple = 10;
static const int _luckyGiftBurstMinAwardAmount = 5000;
BuildContext? context;
@ -119,6 +123,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
int notifcationUnReadCount = 0;
SCBroadCastLuckGiftPush? currentPlayingLuckGift;
final Queue<SCBroadCastLuckGiftPush?> _luckGiftPushQueue = Queue();
String? _currentLuckGiftPushKey;
Debouncer debouncer = Debouncer();
List<V2TimConversation> conversationList = [];
@ -534,6 +539,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
Future<V2TimCallback> joinRoomGroup(String groupID, String message) async {
_luckGiftPushQueue.clear();
currentPlayingLuckGift = null;
_currentLuckGiftPushKey = null;
var joinResult = await TencentImSDKPlugin.v2TIMManager.joinGroup(
groupID: groupID,
message: message,
@ -1025,7 +1031,14 @@ class RealTimeMessagingManager extends ChangeNotifier {
required String source,
}) {
final rewardData = broadCastRes.data;
if (rewardData == null || !rewardData.shouldShowGlobalNews) {
if (rewardData == null) {
return;
}
if (_isLuckyGiftInCurrentRoom(broadCastRes)) {
addluckGiftPushQueue(broadCastRes);
}
if (!rewardData.shouldShowGlobalNews ||
(rewardData.multiple ?? 0) < _luckyGiftFloatMinMultiple) {
return;
}
if (source == 'broadcast' && _isLuckyGiftInCurrentRoom(broadCastRes)) {
@ -1428,11 +1441,25 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
} else if (msg.type == SCRoomMsgType.gift) {
final gift = msg.gift;
final special = gift?.special ?? "";
final giftSourceUrl = gift?.giftSourceUrl ?? "";
if (gift == null) {
_giftFxLog(
'recv gift msg skipped reason=no_gift '
'fromUserId=${msg.user?.id} '
'toUserId=${msg.toUser?.id} '
'quantity=${msg.number}',
);
} else {
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
);
final special = gift.special ?? "";
final giftSourceUrl = gift.giftSourceUrl ?? "";
final hasSource = giftSourceUrl.isNotEmpty;
final hasAnimation = scGiftHasAnimationSpecial(special);
final hasGlobalGift = special.contains(SCGiftType.GLOBAL_GIFT.name);
final hasGlobalGift = special.contains(
SCGiftType.GLOBAL_GIFT.name,
);
final hasFullScreenEffect = scGiftHasFullScreenEffect(special);
_giftFxLog(
'recv gift msg '
@ -1440,8 +1467,8 @@ class RealTimeMessagingManager extends ChangeNotifier {
'fromUserName=${msg.user?.userNickname} '
'toUserId=${msg.toUser?.id} '
'toUserName=${msg.toUser?.userNickname} '
'giftId=${gift?.id} '
'giftName=${gift?.giftName} '
'giftId=${gift.id} '
'giftName=${gift.giftName} '
'giftSourceUrl=$giftSourceUrl '
'special=$special '
'hasSource=$hasSource '
@ -1450,57 +1477,52 @@ class RealTimeMessagingManager extends ChangeNotifier {
'hasFullScreenEffect=$hasFullScreenEffect '
'effectsEnabled=${SCGlobalConfig.isGiftSpecialEffects}',
);
if (msg.gift!.giftSourceUrl != null && msg.gift!.special != null) {
if (scGiftHasFullScreenEffect(msg.gift!.special)) {
if (giftSourceUrl.isNotEmpty && special.isNotEmpty) {
if (scGiftHasFullScreenEffect(special)) {
if (SCGlobalConfig.isGiftSpecialEffects &&
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).shouldShowRoomVisualEffects) {
rtcProvider.shouldShowRoomVisualEffects) {
_giftFxLog(
'trigger player play path=${msg.gift!.giftSourceUrl} '
'giftId=${msg.gift?.id} giftName=${msg.gift?.giftName}',
'trigger player play path=$giftSourceUrl '
'giftId=${gift.id} giftName=${gift.giftName}',
);
SCGiftVapSvgaManager().play(msg.gift!.giftSourceUrl!);
SCGiftVapSvgaManager().play(giftSourceUrl);
} else {
_giftFxLog(
'skip player play because visual effects disabled '
'giftId=${msg.gift?.id} '
'giftId=${gift.id} '
'isGiftSpecialEffects=${SCGlobalConfig.isGiftSpecialEffects} '
'roomVisible=${Provider.of<RealTimeCommunicationManager>(context!, listen: false).shouldShowRoomVisualEffects}',
'roomVisible=${rtcProvider.shouldShowRoomVisualEffects}',
);
}
} else {
_giftFxLog(
'skip player play because special does not include '
'${SCGiftType.ANIMSCION.name}/$kSCGiftAnimationSpecialAlias/${SCGiftType.GLOBAL_GIFT.name} '
'giftId=${msg.gift?.id} special=${msg.gift?.special}',
'giftId=${gift.id} special=${gift.special}',
);
}
} else {
_giftFxLog(
'skip player play because giftSourceUrl or special is null '
'giftId=${msg.gift?.id} '
'giftSourceUrl=${msg.gift?.giftSourceUrl} '
'special=${msg.gift?.special}',
'skip player play because giftSourceUrl or special is empty '
'giftId=${gift.id} '
'giftSourceUrl=${gift.giftSourceUrl} '
'special=${gift.special}',
);
}
if (Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).currenRoom?.roomProfile?.roomSetting?.showHeartbeat ??
if (rtcProvider
.currenRoom
?.roomProfile
?.roomSetting
?.showHeartbeat ??
false) {
debouncer.debounce(
duration: Duration(milliseconds: 350),
onDebounce: () {
Provider.of<RealTimeCommunicationManager>(
context!,
listen: false,
).retrieveMicrophoneList();
rtcProvider.requestGiftTriggeredMicRefresh();
},
);
}
num coins = msg.number! * msg.gift!.giftCandy!;
final coins = (msg.number ?? 0) * (gift.giftCandy ?? 0);
if (coins > 9999) {
OverlayManager().addMessage(
SCFloatingMessage(
@ -1509,13 +1531,14 @@ class RealTimeMessagingManager extends ChangeNotifier {
userName: msg.user?.userNickname ?? "",
toUserName: msg.toUser?.userNickname ?? "",
toUserAvatarUrl: msg.toUser?.userAvatar ?? "",
giftUrl: msg.gift!.giftPhoto,
giftUrl: gift.giftPhoto,
number: msg.number,
coins: coins,
roomId: msg.msg,
),
);
}
}
} else if (msg.type == SCRoomMsgType.luckGiftAnimOther) {
final hideLGiftAnimal =
Provider.of<GiftProvider>(
@ -1686,6 +1709,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomChatMsgList.clear();
_luckGiftPushQueue.clear();
currentPlayingLuckGift = null;
_currentLuckGiftPushKey = null;
onNewMessageListenerGroupMap.forEach((k, v) {
v = null;
});
@ -1694,6 +1718,20 @@ class RealTimeMessagingManager extends ChangeNotifier {
void addluckGiftPushQueue(SCBroadCastLuckGiftPush broadCastRes) {
if (SCGlobalConfig.isLuckGiftSpecialEffects) {
if (!shouldPlayLuckyGiftBurst(broadCastRes.data)) {
return;
}
final eventKey = _luckyGiftPushEventKey(broadCastRes);
if (eventKey.isNotEmpty) {
if (_currentLuckGiftPushKey == eventKey) {
return;
}
for (final queued in _luckGiftPushQueue) {
if (queued != null && _luckyGiftPushEventKey(queued) == eventKey) {
return;
}
}
}
while (_luckGiftPushQueue.length >= _maxLuckGiftPushQueueLength) {
_luckGiftPushQueue.removeFirst();
}
@ -1702,8 +1740,19 @@ class RealTimeMessagingManager extends ChangeNotifier {
}
}
static bool shouldPlayLuckyGiftBurst(Data? rewardData) {
if (rewardData == null) {
return false;
}
final awardAmount = rewardData.awardAmount ?? 0;
final multiple = rewardData.multiple ?? 0;
return awardAmount > _luckyGiftBurstMinAwardAmount ||
multiple >= _luckyGiftBurstMinMultiple;
}
void cleanLuckGiftBackCoins() {
_luckGiftPushQueue.clear();
_currentLuckGiftPushKey = null;
}
void playLuckGiftBackCoins() {
@ -1711,14 +1760,34 @@ class RealTimeMessagingManager extends ChangeNotifier {
return;
}
currentPlayingLuckGift = _luckGiftPushQueue.removeFirst();
_currentLuckGiftPushKey =
currentPlayingLuckGift == null
? null
: _luckyGiftPushEventKey(currentPlayingLuckGift!);
notifyListeners();
Future.delayed(Duration(milliseconds: 3000), () {
currentPlayingLuckGift = null;
_currentLuckGiftPushKey = null;
notifyListeners();
playLuckGiftBackCoins();
});
}
String _luckyGiftPushEventKey(SCBroadCastLuckGiftPush broadCastRes) {
final rewardData = broadCastRes.data;
if (rewardData == null) {
return '';
}
return '${rewardData.roomId ?? ""}|'
'${rewardData.giftId ?? ""}|'
'${rewardData.sendUserId ?? ""}|'
'${rewardData.acceptUserId ?? ""}|'
'${rewardData.giftQuantity ?? 0}|'
'${rewardData.awardAmount ?? 0}|'
'${rewardData.multiple ?? 0}|'
'${rewardData.normalizedMultipleType}';
}
void updateNotificationCount(int count) {
notifcationUnReadCount = 0;
allUnReadCount =

View File

@ -17,6 +17,8 @@ import '../../shared/data_sources/models/enum/sc_banner_type.dart';
import '../../shared/data_sources/models/enum/sc_gift_type.dart';
class SCAppGeneralManager extends ChangeNotifier {
static const int _maxInitialFullScreenGiftPreloads = 4;
List<CountryMode> countryModeList = [];
final Map<String, List<Country>> _countryMap = {};
final Map<String, Country> _countryByNameMap = {};
@ -276,6 +278,8 @@ class SCAppGeneralManager extends ChangeNotifier {
}
void downLoad(List<SocialChatGiftRes> giftResList) {
final scheduledPaths = <String>{};
var scheduledCount = 0;
for (var gift in giftResList) {
final giftSourceUrl = gift.giftSourceUrl ?? "";
if (giftSourceUrl.isEmpty) {
@ -284,7 +288,14 @@ class SCAppGeneralManager extends ChangeNotifier {
if (!scGiftHasFullScreenEffect(gift.special)) {
continue;
}
if (!scheduledPaths.add(giftSourceUrl)) {
continue;
}
SCGiftVapSvgaManager().preload(giftSourceUrl);
scheduledCount += 1;
if (scheduledCount >= _maxInitialFullScreenGiftPreloads) {
break;
}
}
}

View File

@ -81,6 +81,7 @@ class GiftAnimationManager extends ChangeNotifier {
if (target.giftName.isEmpty) {
target.giftName = incoming.giftName;
}
target.rewardAmount = target.rewardAmount + incoming.rewardAmount;
if (incoming.rewardAmountText.isNotEmpty) {
target.rewardAmountText = incoming.rewardAmountText;
}

View File

@ -17,7 +17,7 @@ class MicRes {
SocialChatUserProfile? user,
String? type,
String? roomToken,
String?number
String? number,
}) {
_roomId = roomId;
_micIndex = micIndex;
@ -35,7 +35,10 @@ class MicRes {
_micIndex = json['micIndex'];
_micLock = json['micLock'];
_micMute = json['micMute'];
_user = json['user'] != null ? SocialChatUserProfile.fromJson(json['user']) : null;
_user =
json['user'] != null
? SocialChatUserProfile.fromJson(json['user'])
: null;
_roomToken = json['roomToken'];
_type = json['type'];
_number = json['number'];
@ -59,6 +62,7 @@ class MicRes {
bool? micMute,
String? type,
SocialChatUserProfile? user,
bool clearUser = false,
String? roomToken,
String? emojiPath,
String? number,
@ -67,7 +71,7 @@ class MicRes {
micIndex: micIndex ?? _micIndex,
micLock: micLock ?? _micLock,
micMute: micMute ?? _micMute,
user: user ?? _user,
user: clearUser ? null : user ?? _user,
roomToken: roomToken ?? _roomToken,
emojiPath: emojiPath ?? _emojiPath,
type: type ?? _type,

View File

@ -13,7 +13,6 @@ class SCFloatingMessage {
num? number;
num? multiple;
int priority = 10; //
int aggregateVersion = 0;
SCFloatingMessage({
this.type = 0,
@ -30,7 +29,6 @@ class SCFloatingMessage {
this.coins = 0,
this.priority = 10,
this.multiple = 10,
this.aggregateVersion = 0,
});
SCFloatingMessage.fromJson(dynamic json) {
@ -48,7 +46,6 @@ class SCFloatingMessage {
number = json['number'];
priority = json['priority'];
multiple = json['multiple'];
aggregateVersion = json['aggregateVersion'] ?? 0;
}
Map<String, dynamic> toJson() {
@ -67,7 +64,6 @@ class SCFloatingMessage {
map['number'] = number;
map['priority'] = priority;
map['multiple'] = multiple;
map['aggregateVersion'] = aggregateVersion;
return map;
}
}

View File

@ -20,7 +20,6 @@ class OverlayManager {
);
bool _isPlaying = false;
OverlayEntry? _currentOverlayEntry;
SCFloatingMessage? _currentMessage;
bool _isProcessing = false;
bool _isDisposed = false;
@ -34,9 +33,6 @@ class OverlayManager {
void addMessage(SCFloatingMessage message) {
if (_isDisposed) return;
if (SCGlobalConfig.isFloatingAnimationInGlobal) {
if (_tryAggregateMessage(message)) {
return;
}
_messageQueue.add(message);
_safeScheduleNext();
} else {
@ -47,74 +43,6 @@ class OverlayManager {
}
}
bool _tryAggregateMessage(SCFloatingMessage incoming) {
if (!_supportsAggregation(incoming)) {
return false;
}
if (_currentMessage != null &&
_isSameAggregatedFloatingMessage(_currentMessage!, incoming)) {
_mergeFloatingMessage(_currentMessage!, incoming);
_currentOverlayEntry?.markNeedsBuild();
return true;
}
for (final queuedMessage in _messageQueue.unorderedElements) {
if (_isSameAggregatedFloatingMessage(queuedMessage, incoming)) {
_mergeFloatingMessage(queuedMessage, incoming);
return true;
}
}
return false;
}
bool _supportsAggregation(SCFloatingMessage message) {
return message.type == 0;
}
bool _isSameAggregatedFloatingMessage(
SCFloatingMessage existing,
SCFloatingMessage incoming,
) {
return existing.type == incoming.type &&
existing.roomId == incoming.roomId &&
existing.userId == incoming.userId &&
existing.toUserId == incoming.toUserId &&
existing.giftUrl == incoming.giftUrl;
}
void _mergeFloatingMessage(
SCFloatingMessage target,
SCFloatingMessage incoming,
) {
target.coins = (target.coins ?? 0) + (incoming.coins ?? 0);
target.number = (target.number ?? 0) + (incoming.number ?? 0);
final currentMultiple = target.multiple ?? 0;
final incomingMultiple = incoming.multiple ?? 0;
target.multiple =
currentMultiple >= incomingMultiple
? currentMultiple
: incomingMultiple;
if ((target.userAvatarUrl ?? '').isEmpty) {
target.userAvatarUrl = incoming.userAvatarUrl;
}
if ((target.userName ?? '').isEmpty) {
target.userName = incoming.userName;
}
if ((target.toUserName ?? '').isEmpty) {
target.toUserName = incoming.toUserName;
}
if ((target.giftUrl ?? '').isEmpty) {
target.giftUrl = incoming.giftUrl;
}
if ((target.roomId ?? '').isEmpty) {
target.roomId = incoming.roomId;
}
target.priority =
target.priority >= incoming.priority
? target.priority
: incoming.priority;
target.aggregateVersion += 1;
}
void _safeScheduleNext() {
if (_isProcessing || _isPlaying || _messageQueue.isEmpty) return;
_isProcessing = true;
@ -162,11 +90,9 @@ class OverlayManager {
void _playMessage(SCFloatingMessage message) {
_isPlaying = true;
_currentMessage = message;
final context = navigatorKey.currentState?.context;
if (context == null || !context.mounted) {
_isPlaying = false;
_currentMessage = null;
_safeScheduleNext();
return;
}
@ -195,12 +121,10 @@ class OverlayManager {
try {
_currentOverlayEntry?.remove();
_currentOverlayEntry = null;
_currentMessage = null;
_isPlaying = false;
_safeScheduleNext();
} catch (e) {
debugPrint('清理悬浮消息出错: $e');
_currentMessage = null;
_isPlaying = false;
_safeScheduleNext();
}
@ -209,9 +133,6 @@ class OverlayManager {
switch (message.type) {
case 0:
return FloatingLuckGiftScreenWidget(
key: ValueKey<String>(
'luck_${message.userId}_${message.toUserId}_${message.giftUrl}_${message.aggregateVersion}',
),
message: message,
onAnimationCompleted: onComplete,
);
@ -253,7 +174,6 @@ class OverlayManager {
_isDisposed = true;
_currentOverlayEntry?.remove();
_currentOverlayEntry = null;
_currentMessage = null;
_messageQueue.clear();
_isPlaying = false;
_isProcessing = false;

View File

@ -324,9 +324,12 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
'dayIndex=${checkInResult.dayIndex} '
'checkedToday=${latestData.checkedToday}',
);
SCTts.show(
checkInResult.alreadySigned ? l10n.signedin : l10n.receiveSucc,
);
final toastMessage =
checkInResult.alreadySigned
? l10n.signedin
: _rewardToastMessage(l10n, checkInResult.rewardItems);
debugPrint('[SignInReward][Dialog] reward toast=$toastMessage');
SCTts.show(toastMessage);
SmartDialog.dismiss(tag: DailySignInDialog.dialogTag);
} catch (error) {
SCLoadingManager.hide();
@ -365,6 +368,66 @@ class _DailySignInDialogState extends State<DailySignInDialog> {
);
}
String _rewardToastMessage(
SCAppLocalizations l10n,
List<SCSignInRewardItem> rewardItems,
) {
final rewardSummary = _rewardSummary(l10n, rewardItems);
if (rewardSummary.isEmpty) {
return l10n.receiveSucc;
}
return l10n.signInRewardReceived(rewardSummary);
}
String _rewardSummary(
SCAppLocalizations l10n,
List<SCSignInRewardItem> rewardItems,
) {
final rewards =
rewardItems
.map((item) => _rewardItemLabel(l10n, item))
.where((item) => item.isNotEmpty)
.toList();
if (rewards.isEmpty) {
return '';
}
return rewards.join(', ');
}
String _rewardItemLabel(SCAppLocalizations l10n, SCSignInRewardItem item) {
final type = item.type.trim().toUpperCase();
final quantity =
item.quantity > 0 ? item.quantity.toString() : item.content.trim();
if (type == 'GOLD') {
return l10n.coins2(quantity.isEmpty ? '0' : quantity);
}
final name = _pickFirstNonEmpty([
item.name,
item.remark,
item.content,
item.type,
]);
if (name.isEmpty) {
return '';
}
if (item.quantity > 1) {
return '$name x${item.quantity}';
}
return name;
}
String _pickFirstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim() ?? '';
if (trimmed.isNotEmpty) {
return trimmed;
}
}
return '';
}
String _debugItemsSummary(List<DailySignInDialogItem> items) {
return items
.map(

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/app_localizations.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/main.dart';
import 'package:provider/provider.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
@ -22,6 +21,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
with TickerProviderStateMixin {
static const String _luckyGiftRewardFrameAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_frame.svga";
late final GiftAnimationManager _giftAnimationManager;
@override
Widget build(BuildContext context) {
@ -55,8 +55,12 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
animation: bean.controller,
builder: (context, child) {
final showLuckyRewardFrame = gift.showLuckyRewardFrame;
final tickerMargin =
showLuckyRewardFrame
? bean.luckyGiftPinnedMargin
: bean.verticalAnimation.value;
return Container(
margin: bean.verticalAnimation.value,
margin: tickerMargin,
width:
ScreenUtil().screenWidth * (showLuckyRewardFrame ? 0.96 : 0.78),
child: _buildGiftTickerCard(context, gift, bean.sizeAnimation.value),
@ -250,7 +254,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"+${gift.rewardAmountText}",
"+${_giftRewardAmountText(gift)}",
maxLines: 1,
style: TextStyle(
fontSize: 16.sp,
@ -301,18 +305,33 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
return count % 1 == 0 ? count.toInt().toString() : count.toString();
}
String _giftRewardAmountText(LGiftModel gift) {
final rewardAmount = gift.rewardAmount;
if (rewardAmount > 0) {
if (rewardAmount > 9999) {
return "${(rewardAmount / 1000).toStringAsFixed(0)}k";
}
if (rewardAmount % 1 == 0) {
return rewardAmount.toInt().toString();
}
return rewardAmount.toString();
}
return gift.rewardAmountText;
}
@override
void dispose() {
Provider.of<GiftAnimationManager>(
navigatorKey.currentState!.context,
listen: false,
).cleanupAnimationResources();
_giftAnimationManager.cleanupAnimationResources();
super.dispose();
}
@override
void initState() {
super.initState();
_giftAnimationManager = Provider.of<GiftAnimationManager>(
context,
listen: false,
);
initAnimal();
}
@ -327,6 +346,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
vsync: this,
);
bean.controller = controller;
bean.luckyGiftPinnedMargin = EdgeInsets.only(top: top);
// bean.transverseAnimation = Tween<Offset>(
// begin: Offset(ScreenUtil().screenWidth, 0),
// end: Offset(0, 0),
@ -357,43 +377,28 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
beans[0].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
Provider.of<GiftAnimationManager>(
context,
listen: false,
).markAnimationAsFinished(0);
_giftAnimationManager.markAnimationAsFinished(0);
}
});
beans[1].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
Provider.of<GiftAnimationManager>(
context,
listen: false,
).markAnimationAsFinished(1);
_giftAnimationManager.markAnimationAsFinished(1);
}
});
beans[2].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
Provider.of<GiftAnimationManager>(
context,
listen: false,
).markAnimationAsFinished(2);
_giftAnimationManager.markAnimationAsFinished(2);
}
});
beans[3].controller.addStatusListener((state) {
if (state == AnimationStatus.completed) {
//
Provider.of<GiftAnimationManager>(
context,
listen: false,
).markAnimationAsFinished(3);
_giftAnimationManager.markAnimationAsFinished(3);
}
});
Provider.of<GiftAnimationManager>(
context,
listen: false,
).attachAnimationControllers(beans);
_giftAnimationManager.attachAnimationControllers(beans);
}
String getBg(String type) {
@ -404,6 +409,7 @@ class _GiftAnimalPageState extends State<LGiftAnimalPage>
class LGiftScrollingScreenAnimsBean {
// late Animation<Offset> transverseAnimation;
late Animation<EdgeInsets> verticalAnimation;
late EdgeInsets luckyGiftPinnedMargin;
late AnimationController controller;
late Animation<double> sizeAnimation;
}
@ -430,6 +436,9 @@ class LGiftModel {
//
String rewardAmountText = "";
//
num rewardAmount = 0;
//
bool showLuckyRewardFrame = false;

View File

@ -6,8 +6,6 @@ import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_svga_asset_widget.dart';
class LuckGiftNomorAnimWidget extends StatefulWidget {
static const num _rewardBurstMinAwardAmount = 5000;
static const num _rewardBurstMinMultiple = 10;
static const String _rewardBurstAssetPath =
"sc_images/room/anim/luck_gift/luck_gift_reward_burst.svga";
@ -19,11 +17,11 @@ class LuckGiftNomorAnimWidget extends StatefulWidget {
}
class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
String? _currentBurstEventId;
bool _isRewardAmountVisible = false;
bool _shouldPlayRewardBurst(Data rewardData) {
final awardAmount = rewardData.awardAmount ?? 0;
final multiple = rewardData.multiple ?? 0;
return awardAmount > LuckGiftNomorAnimWidget._rewardBurstMinAwardAmount ||
multiple > LuckGiftNomorAnimWidget._rewardBurstMinMultiple;
return RealTimeMessagingManager.shouldPlayLuckyGiftBurst(rewardData);
}
String _formatAwardAmount(num awardAmount) {
@ -43,6 +41,8 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
builder: (context, provider, child) {
final rewardData = provider.currentPlayingLuckGift?.data;
if (rewardData == null || !_shouldPlayRewardBurst(rewardData)) {
_currentBurstEventId = null;
_isRewardAmountVisible = false;
return const SizedBox.shrink();
}
final rewardAnimationKey = ValueKey<String>(
@ -52,6 +52,10 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
'|${rewardData.multiple ?? 0}'
'|${rewardData.normalizedMultipleType}',
);
if (_currentBurstEventId != rewardAnimationKey.value) {
_currentBurstEventId = rewardAnimationKey.value;
_isRewardAmountVisible = false;
}
return SizedBox(
height: 380.w,
child: Stack(
@ -66,18 +70,45 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
height: 380.w,
fit: BoxFit.fitWidth,
allowDrawingOverflow: true,
clearsAfterStop: true,
onPlaybackStarted: () {
if (!mounted) {
return;
}
if (!_isRewardAmountVisible) {
setState(() {
_isRewardAmountVisible = true;
});
}
},
onPlaybackCompleted: () {
if (!mounted) {
return;
}
if (_isRewardAmountVisible) {
setState(() {
_isRewardAmountVisible = false;
});
}
},
),
),
Positioned(
top: 154.w,
if (_isRewardAmountVisible)
Positioned.fill(
child: Align(
alignment: const Alignment(0, 0.12),
child: Transform.translate(
offset: Offset(0, 0),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: ScreenUtil().screenWidth * 0.56,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: EdgeInsets.only(left: 6.w, right: 2.w),
child: Text(
"+${_formatAwardAmount(rewardData.awardAmount ?? 0)}",
_formatAwardAmount(rewardData.awardAmount ?? 0),
maxLines: 1,
style: TextStyle(
fontSize: 28.sp,
@ -97,6 +128,9 @@ class _LuckGiftNomorAnimWidgetState extends State<LuckGiftNomorAnimWidget> {
),
),
),
),
),
),
],
),
);

View File

@ -50,17 +50,20 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
Widget build(BuildContext context) {
return SizedBox(
height: _floatingButtonHostHeight.w,
child: Consumer<RtcProvider>(
builder: (context, rtcProvider, child) {
final showMic = _shouldShowMic(rtcProvider);
child: Selector<RtcProvider, _RoomBottomSnapshot>(
selector:
(context, provider) => _RoomBottomSnapshot(
showMic: _shouldShowMic(provider),
isMic: provider.isMic,
),
builder: (context, bottomSnapshot, child) {
return LayoutBuilder(
builder: (context, constraints) {
final inputWidth = constraints.maxWidth / 3;
final giftCenterX = _resolveGiftCenterX(
maxWidth: constraints.maxWidth,
inputWidth: inputWidth,
showMic: showMic,
showMic: bottomSnapshot.showMic,
);
return Stack(
@ -78,9 +81,9 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
child: SizedBox(
height: _bottomBarHeight.w,
child: _buildBottomBar(
rtcProvider: rtcProvider,
inputWidth: inputWidth,
showMic: showMic,
showMic: bottomSnapshot.showMic,
isMic: bottomSnapshot.isMic,
),
),
),
@ -105,9 +108,9 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
}
Widget _buildBottomBar({
required RtcProvider rtcProvider,
required double inputWidth,
required bool showMic,
required bool isMic,
}) {
final giftAction = _buildGiftAction();
final messageAction = _buildMessageAction();
@ -126,7 +129,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
children: [
_buildChatEntry(inputWidth),
giftAction,
_buildMicAction(rtcProvider),
_buildMicAction(isMic: isMic),
menuAction,
messageAction,
],
@ -265,10 +268,11 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
);
}
Widget _buildMicAction(RtcProvider provider) {
Widget _buildMicAction({required bool isMic}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
final provider = context.read<RtcProvider>();
setState(() {
provider.isMic = !provider.isMic;
@ -298,7 +302,7 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
},
child: RoomBottomCircleAction(
child: Image.asset(
"sc_images/room/${provider.isMic ? 'sc_icon_botton_mic_close' : 'sc_icon_botton_mic_open'}.png",
"sc_images/room/${isMic ? 'sc_icon_botton_mic_close' : 'sc_icon_botton_mic_open'}.png",
width: 30.w,
height: 30.w,
fit: BoxFit.contain,
@ -320,3 +324,23 @@ class _RoomBottomWidgetState extends State<RoomBottomWidget> {
return show;
}
}
class _RoomBottomSnapshot {
const _RoomBottomSnapshot({required this.showMic, required this.isMic});
final bool showMic;
final bool isMic;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomBottomSnapshot &&
other.showMic == showMic &&
other.isMic == isMic;
}
@override
int get hashCode => Object.hash(showMic, isMic);
}

View File

@ -23,8 +23,22 @@ class RoomHeadWidget extends StatefulWidget {
class _RoomHeadWidgetState extends State<RoomHeadWidget> {
@override
Widget build(BuildContext context) {
return Consumer<RtcProvider>(
builder: (context, provider, child) {
return Selector<RtcProvider, _RoomHeadSnapshot>(
selector: (context, provider) {
final room = provider.currenRoom;
final roomOwnerUserId = room?.roomProfile?.roomProfile?.userId ?? "";
final currentUserId =
AccountStorage().getCurrentUser()?.userProfile?.id ?? "";
return _RoomHeadSnapshot(
roomProfileId: room?.roomProfile?.roomProfile?.id,
roomCover: room?.roomProfile?.roomProfile?.roomCover,
roomName: room?.roomProfile?.roomProfile?.roomName ?? "",
roomDisplayId: room?.roomProfile?.userProfile?.getID() ?? "",
roomOwnerUserId: roomOwnerUserId,
isRoomOwner: roomOwnerUserId == currentUserId,
);
},
builder: (context, roomSnapshot, child) {
return Row(
children: [
Row(
@ -32,8 +46,8 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
GestureDetector(
onTap: () {
showBottomInBottomDialog(
context!,
RoomDetailPage(provider.isFz()),
context,
RoomDetailPage(context.read<RtcProvider>().isFz()),
);
},
child: Container(
@ -48,16 +62,8 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
children: [
netImage(
url: resolveRoomCoverUrl(
provider
.currenRoom
?.roomProfile
?.roomProfile
?.id,
provider
.currenRoom
?.roomProfile
?.roomProfile
?.roomCover,
roomSnapshot.roomProfileId,
roomSnapshot.roomCover,
),
defaultImg: kRoomCoverDefaultImg,
width: 28.w,
@ -99,22 +105,9 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
maxHeight: 17.w,
),
child:
(provider
.currenRoom
?.roomProfile
?.roomProfile
?.roomName
?.length ??
0) >
10
roomSnapshot.roomName.length > 10
? Marquee(
text:
provider
.currenRoom
?.roomProfile
?.roomProfile
?.roomName ??
"",
text: roomSnapshot.roomName,
style: TextStyle(
fontSize: 14.sp,
color: Color(0xffffffff),
@ -137,12 +130,7 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
decelerationCurve: Curves.easeOut,
)
: Text(
provider
.currenRoom
?.roomProfile
?.roomProfile
?.roomName ??
'',
roomSnapshot.roomName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@ -155,7 +143,7 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
),
text(
"ID:${provider.currenRoom?.roomProfile?.userProfile?.getID()}",
"ID:${roomSnapshot.roomDisplayId}",
fontSize: 13.sp,
fontWeight: FontWeight.w600,
textColor: Colors.white70,
@ -163,11 +151,7 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
],
),
SizedBox(width: 6.w),
provider.currenRoom?.roomProfile?.roomProfile?.userId !=
AccountStorage()
.getCurrentUser()
?.userProfile
?.id
!roomSnapshot.isRoomOwner
? Selector<RtcProvider, bool>(
selector:
(c, p) =>
@ -182,7 +166,9 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
height: 22.w,
),
onTap: () {
provider.followCurrentVoiceRoom();
context
.read<RtcProvider>()
.followCurrentVoiceRoom();
},
)
: Container();
@ -203,7 +189,7 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
height: 32.w,
),
onTap: () {
if (provider.isFz()) {
if (context.read<RtcProvider>().isFz()) {
SCNavigatorUtils.push(
context,
"${VoiceRoomRoute.roomEdit}?need=false",
@ -234,8 +220,8 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
showCenterDialog(
context,
ExitMinRoomPage(
provider.isFz(),
provider.currenRoom?.roomProfile?.roomProfile?.id ?? "",
context.read<RtcProvider>().isFz(),
roomSnapshot.roomProfileId ?? "",
),
barrierColor: Colors.black54,
);
@ -249,3 +235,45 @@ class _RoomHeadWidgetState extends State<RoomHeadWidget> {
);
}
}
class _RoomHeadSnapshot {
const _RoomHeadSnapshot({
required this.roomProfileId,
required this.roomCover,
required this.roomName,
required this.roomDisplayId,
required this.roomOwnerUserId,
required this.isRoomOwner,
});
final String? roomProfileId;
final String? roomCover;
final String roomName;
final String roomDisplayId;
final String roomOwnerUserId;
final bool isRoomOwner;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomHeadSnapshot &&
other.roomProfileId == roomProfileId &&
other.roomCover == roomCover &&
other.roomName == roomName &&
other.roomDisplayId == roomDisplayId &&
other.roomOwnerUserId == roomOwnerUserId &&
other.isRoomOwner == isRoomOwner;
}
@override
int get hashCode => Object.hash(
roomProfileId,
roomCover,
roomName,
roomDisplayId,
roomOwnerUserId,
isRoomOwner,
);
}

View File

@ -6,6 +6,7 @@ import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
import 'package:yumi/ui_kit/components/text/sc_text.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/tools/sc_lk_dialog_util.dart';
import 'package:yumi/services/room/rc_room_manager.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
@ -29,17 +30,32 @@ class _RoomOnlineUserWidgetState extends State<RoomOnlineUserWidget> {
double get _onlineUsersShellWidth =>
_onlineUsersAvatarsWidth + _onlineUsersCounterWidth;
void _openRoomOnlinePage(RtcProvider ref) {
void _openRoomOnlinePage() {
showBottomInBottomDialog(
context,
RoomOnlinePage(roomId: ref.currenRoom?.roomProfile?.roomProfile?.id),
RoomOnlinePage(
roomId:
context
.read<RtcProvider>()
.currenRoom
?.roomProfile
?.roomProfile
?.id,
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<RtcProvider>(
builder: (context, ref, child) {
return Selector<RtcProvider, _RoomOnlineUsersSnapshot>(
selector:
(context, provider) => _RoomOnlineUsersSnapshot(
onlineUsers: List<SocialChatUserProfile>.unmodifiable(
provider.onlineUsers,
),
),
builder: (context, onlineSnapshot, child) {
final onlineUsers = onlineSnapshot.onlineUsers;
return Row(
children: [
_buildExperience(),
@ -48,8 +64,8 @@ class _RoomOnlineUserWidgetState extends State<RoomOnlineUserWidget> {
child: Align(
alignment: Alignment.centerRight,
child:
ref.onlineUsers.isNotEmpty
? _buildOnlineUsers(ref)
onlineUsers.isNotEmpty
? _buildOnlineUsers(onlineUsers)
: _buildOnlineUsersPlaceholder(),
),
),
@ -59,12 +75,12 @@ class _RoomOnlineUserWidgetState extends State<RoomOnlineUserWidget> {
);
}
Widget _buildOnlineUsers(RtcProvider ref) {
Widget _buildOnlineUsers(List<SocialChatUserProfile> onlineUsers) {
return Padding(
padding: EdgeInsets.only(right: 5.w),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openRoomOnlinePage(ref),
onTap: _openRoomOnlinePage,
child: SizedBox(
width: _onlineUsersShellWidth,
height: _onlineUsersShellHeight,
@ -80,13 +96,13 @@ class _RoomOnlineUserWidgetState extends State<RoomOnlineUserWidget> {
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(ref.onlineUsers.length, (index) {
children: List.generate(onlineUsers.length, (index) {
return Transform.translate(
offset: Offset(-3.w * index, 0),
child: Padding(
padding: EdgeInsets.only(right: 0.w),
child: netImage(
url: ref.onlineUsers[index].userAvatar ?? "",
url: onlineUsers[index].userAvatar ?? "",
width: 23.w,
height: 23.w,
defaultImg:
@ -117,11 +133,7 @@ class _RoomOnlineUserWidgetState extends State<RoomOnlineUserWidget> {
width: 12.w,
height: 12.sp,
),
text(
"${ref.onlineUsers.length}",
fontSize: 9,
lineHeight: 1,
),
text("${onlineUsers.length}", fontSize: 9, lineHeight: 1),
],
),
),
@ -204,3 +216,37 @@ class _RoomOnlineUserWidgetState extends State<RoomOnlineUserWidget> {
);
}
}
class _RoomOnlineUsersSnapshot {
const _RoomOnlineUsersSnapshot({required this.onlineUsers});
final List<SocialChatUserProfile> onlineUsers;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! _RoomOnlineUsersSnapshot ||
other.onlineUsers.length != onlineUsers.length) {
return false;
}
for (int index = 0; index < onlineUsers.length; index++) {
final currentUser = onlineUsers[index];
final otherUser = other.onlineUsers[index];
if (currentUser.id != otherUser.id ||
currentUser.userAvatar != otherUser.userAvatar ||
currentUser.userNickname != otherUser.userNickname ||
currentUser.roles != otherUser.roles ||
currentUser.heartbeatVal != otherUser.heartbeatVal) {
return false;
}
}
return true;
}
@override
int get hashCode => Object.hashAll(
onlineUsers.map((user) => Object.hash(user.id, user.userAvatar)),
);
}

View File

@ -13,12 +13,12 @@ class RoomSeatWidget extends StatefulWidget {
class _RoomSeatWidgetState extends State<RoomSeatWidget> {
int _lastSeatCount = 0;
int _resolvedSeatCount(RtcProvider ref) {
final int seatCount = ref.roomWheatMap.length;
if (!ref.isExitingCurrentVoiceRoomSession && seatCount > 0) {
int _resolvedSeatCount(_RoomSeatLayoutSnapshot snapshot) {
final int seatCount = snapshot.seatCount;
if (!snapshot.isExitingCurrentVoiceRoomSession && seatCount > 0) {
_lastSeatCount = seatCount;
}
if (ref.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) {
if (snapshot.isExitingCurrentVoiceRoomSession && _lastSeatCount > 0) {
return _lastSeatCount;
}
return seatCount;
@ -26,9 +26,15 @@ class _RoomSeatWidgetState extends State<RoomSeatWidget> {
@override
Widget build(BuildContext context) {
return Consumer<RtcProvider>(
builder: (context, ref, child) {
final int seatCount = _resolvedSeatCount(ref);
return Selector<RtcProvider, _RoomSeatLayoutSnapshot>(
selector:
(context, provider) => _RoomSeatLayoutSnapshot(
seatCount: provider.roomWheatMap.length,
isExitingCurrentVoiceRoomSession:
provider.isExitingCurrentVoiceRoomSession,
),
builder: (context, snapshot, child) {
final int seatCount = _resolvedSeatCount(snapshot);
return seatCount == 5
? _buildSeat5()
: (seatCount == 10
@ -177,3 +183,27 @@ class _RoomSeatWidgetState extends State<RoomSeatWidget> {
);
}
}
class _RoomSeatLayoutSnapshot {
const _RoomSeatLayoutSnapshot({
required this.seatCount,
required this.isExitingCurrentVoiceRoomSession,
});
final int seatCount;
final bool isExitingCurrentVoiceRoomSession;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _RoomSeatLayoutSnapshot &&
other.seatCount == seatCount &&
other.isExitingCurrentVoiceRoomSession ==
isExitingCurrentVoiceRoomSession;
}
@override
int get hashCode => Object.hash(seatCount, isExitingCurrentVoiceRoomSession);
}

View File

@ -14,6 +14,9 @@ class SCSvgaAssetWidget extends StatefulWidget {
this.filterQuality = FilterQuality.low,
this.allowDrawingOverflow = false,
this.fallback,
this.clearsAfterStop = false,
this.onPlaybackStarted,
this.onPlaybackCompleted,
});
final String assetPath;
@ -25,6 +28,9 @@ class SCSvgaAssetWidget extends StatefulWidget {
final FilterQuality filterQuality;
final bool allowDrawingOverflow;
final Widget? fallback;
final bool clearsAfterStop;
final VoidCallback? onPlaybackStarted;
final VoidCallback? onPlaybackCompleted;
@override
State<SCSvgaAssetWidget> createState() => _SCSvgaAssetWidgetState();
@ -44,9 +50,20 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
void initState() {
super.initState();
_controller = SVGAAnimationController(vsync: this);
_controller.addStatusListener(_handleAnimationStatusChanged);
_loadAsset();
}
void _handleAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.forward) {
widget.onPlaybackStarted?.call();
return;
}
if (status == AnimationStatus.completed) {
widget.onPlaybackCompleted?.call();
}
}
@override
void didUpdateWidget(covariant SCSvgaAssetWidget oldWidget) {
super.didUpdateWidget(oldWidget);
@ -129,6 +146,7 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
if (!widget.active) {
_controller.stop();
_controller.reset();
widget.onPlaybackCompleted?.call();
return;
}
@ -148,6 +166,7 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
@override
void dispose() {
_controller.removeStatusListener(_handleAnimationStatusChanged);
_controller.dispose();
super.dispose();
}
@ -170,7 +189,7 @@ class _SCSvgaAssetWidgetState extends State<SCSvgaAssetWidget>
child: SVGAImage(
_controller,
fit: widget.fit,
clearsAfterStop: false,
clearsAfterStop: widget.clearsAfterStop,
filterQuality: widget.filterQuality,
allowDrawingOverflow: widget.allowDrawingOverflow,
),

View File

@ -21,17 +21,26 @@
## 已完成模块
- 已按 2026-04-20 最新首页视觉需求,为 Party 房间列表前 3 个房卡接入新的本地排名边框 SVGA桌面“房间排序前三的框”中的 3 份素材已导入工程并挂到首页房卡最上层,仅作用于当前列表前 3 项,其余房卡保持原样;后续又继续为前三房卡底部信息区补了横向与底部安全区,避免外扩边框直接压住国旗、房名和在线人数。同时 `Me` 板块里的 `Recent / Followed` tab 已继续对齐上方首页 tab 的斜体字样式,并移除了点击时的波浪反馈。
- 已按 2026-04-20 最新房间联调继续修正房主在自己房间里的麦位操作回归:当前房主/管理员点击自己所在麦位时,不再被“直接打开资料卡”逻辑短路,会重新回到底部麦位菜单,从而正常看到自己可用的上下麦/禁麦操作;同时上麦、下麦、禁麦、解禁、锁麦、解锁这些动作在接口成功后会立即回写本地麦位状态,并主动补拉一次最新麦位列表,减少房主端自己操作后 UI 状态延迟或短暂错乱。
- 已按 2026-04-20 最新房间联调继续修正房主在自己房间里的麦位操作回归:当前房主/管理员点击自己所在麦位时,不再被“直接打开资料卡”逻辑短路,会重新回到底部麦位菜单,从而正常看到自己可用的上下麦/禁麦操作;同时上麦、下麦、禁麦、解禁、锁麦、解锁这些动作在接口成功后会立即回写本地麦位状态,并主动补拉一次最新麦位列表,减少房主端自己操作后 UI 状态延迟或短暂错乱。最新已继续收紧“换麦”场景:当前会先本地顺滑切到目标麦位,再在短保护窗内拦住把自己打回旧麦位的旧轮询/旧广播快照;同时补修 `MicRes.copyWith(user: null)` 实际不会清空用户的问题,避免切麦时出现双占位或 `1 -> 4 -> 1 -> 4` 这类来回闪动。
- 已按 2026-04-20 最新调整撤掉 `Wallet -> Recharge` 中新增的 MiFaPay 第三方支付 UI 与页面逻辑:当前充值页仅保留原生 `Google Pay / Apple Pay` 入口与商品列表,`Recharge methods` 区域也已收敛为单一原生支付卡片;此前接入的 MiFaPay 方法选择、底部弹窗、H5 收银台页以及对应页面级状态管理已从现有充值链路移除,避免继续对当前版本产生影响。
- 已按 2026-04-18 联调要求继续收口幸运礼物链路:项目默认 API host 已临时切到 `http://192.168.110.43:1100/` 方便直连测试环境;`/gift/give/lucky-gift` 请求体也已补齐为最新结构,除原有 `giftId/quantity/roomId/acceptUserIds/checkCombo` 外,会稳定携带 `gameId: null``accepts: []``dynamicContentId: null``songId: null` 这几组字段,便于和当前后端参数定义保持一致。
- 已继续补齐 `GAME_LUCKY_GIFT` 的 socket 播报与中奖动效:房间群消息收到该类型后,现在会统一落聊天室中奖消息、奖励弹层队列与发送者余额回写,不再只在 `3x+` 时才触发顶部奖励动画;同时全局飘窗已改为按服务端 `globalNews` 字段决定是否展示,避免再用前端本地倍率硬编码去猜。
- 已按最新 UI 口径重排幸运礼物中奖展示:顶部 `LuckGiftNomorAnimWidget` 不再叠加大头像、倍率和奖励框,只在“单个礼物倍率 `> 10x`”或“单次中奖金币 `> 5000`”时负责播全屏 `luck_gift_reward_burst.svga`;原本的 `luck_gift_reward_frame.svga` 已改挂到房间礼物播报条最右侧,并在框内显示 `+formattedAwardAmount`,金额文案直接复用此前大头像下方那一份中奖金币额。
- 已按最新 UI 口径重排幸运礼物中奖展示:顶部 `LuckGiftNomorAnimWidget` 不再叠加大头像、倍率和奖励框,只在“单个礼物倍率 `>= 10x`”或“单次中奖金币 `> 5000`”时负责播全屏 `luck_gift_reward_burst.svga`;原本的 `luck_gift_reward_frame.svga` 已改挂到房间礼物播报条最右侧,并在框内显示 `+formattedAwardAmount`,金额文案直接复用此前大头像下方那一份中奖金币额。
- 已按最新确认撤掉播报条里的 lucky combo 特殊样式:房间礼物播报条右侧的 `xN` 数字现已恢复普通礼物样式,不再在这里做幸运礼物 10/20/50/100... 的特殊放大/描边展示;这些 `lucky_gift_combo_count_xxx.svga` 资源仍保留在幸运礼物倍率/数量命中时走全屏特效链,不再和播报条 UI 混在一起。
- 已继续修正幸运礼物中奖播报条右侧奖励框不显示的问题:根因是 `pubspec.yaml` 之前只显式收录了 `sc_images/room/anim/gift/`,但没有把新的 `sc_images/room/anim/luck_gift/` 子目录单独打进 Flutter 资源清单,导致 `luck_gift_reward_frame.svga` 在运行时可引用但未被实际打包;当前已补齐资源目录声明,并为播报条奖励框增加本地 fallback避免资源异常时再次只剩金额裸字。
- 已按最新文案口径继续收紧幸运礼物中奖消息:聊天室里高倍率幸运礼物提示不再显示冗长的 `Coins / a lucky(magic) gift` 文字,当前已改为直接展示“中奖金额 + 金币图标 + 对应礼物图”,和房间礼物播报条的视觉表达保持一致。
- 已继续微调幸运礼物中奖视觉:房间礼物播报条右侧的 `luck_gift_reward_frame.svga` 已进一步放大,并给右侧区域和主条正文预留了更宽的排版空间,避免资源已经正常显示但因为画布比例和透明边距看起来过小;同时聊天室高亮中奖消息与房间顶部幸运礼物横幅现已统一改成“中奖金额 + 金币图标 + from + 礼物图”的表达,去掉原先 `Coins / a lucky(magic) gift` 这类冗长英文文案;另外全屏 `luck_gift_reward_burst.svga` 也已补上中部金额文案,避免播发时只见特效不见本次实际中奖金币数。
- 已按 2026-04-20 最新联调继续收口幸运礼物播报噪音:顶部幸运礼物飘屏现在会对同一房间、同一送礼人、同一接收人、同一礼物的连续中奖做队列内聚合,不再每来一条都重新追加一个新飘屏;当前展示中的那一条也会直接叠加金币额并刷新,避免“刷礼过快时飘屏一条接一条排队刷过去”。同时飘屏中奖文案已强制收为单行显示,防止金额、`from` 和礼物图被挤成上下两行。
- 已继续修复房间礼物播报条偶发出现两条一模一样内容的问题:此前 `GiftAnimationManager` 只会和“正在播放”的同 `labelId` 项做合并,等待队列里的同类播报不会提前去重,因此高频情况下仍可能排出两条完全一样的条目;当前已改为在入队时同时检查“正在播”和“待播”两侧,命中相同播报键时直接原地合并,不再把重复项继续塞进队列。
- 已按 2026-04-20 最新确认继续调整幸运礼物展示逻辑:房间播报条最右侧的中奖金币现已按同一条播报累计值展示,不再每次中奖只用最后一笔金额覆盖前一笔;幸运礼物顶部飘屏则撤回此前的聚合策略,恢复为每条都单独展示,但仅当服务端倍率达到 `5x` 及以上时才触发飘屏,避免低倍率也持续刷屏;同时 `luck_gift_reward_burst.svga` 的触发链路已补上“当前房间广播兜底 + 事件去重”,即便只收到广播消息也能进队列播放,而同一事件不会因为广播/群消息双到达而重复播发。
- 已继续修正幸运礼物 `burst` 的倍率边界判断:`LuckGiftNomorAnimWidget` 内部此前仍使用旧的“倍率 `> 10x`”严格大于判断,导致刚好命中 `10x` 的中奖虽然会走飘屏链路,但不会实际触发 `luck_gift_reward_burst.svga`;当前已统一改为“倍率 `>= 10x` 或单次中奖金币 `> 5000`”。
- 已继续修正幸运礼物 `burst` 的实际播放链路:此前 `currentPlayingLuckGift` 队列会把所有幸运礼物中奖事件都排进去,哪怕只是低倍率、根本不需要播 `luck_gift_reward_burst.svga` 的那类消息,也会先占掉每次 3 秒的播放时段;这样在刷得比较密时,真正命中 `10x+``>5000` 金币的事件只是被压在队列后面,视觉上就会像“明明达标了却没触发”。当前已把队列入口改成只接收真正命中 `burst` 条件的事件,并把命中判断收口到同一处,避免顶部飘屏和 `burst` 各自走不同口径。
- 已继续调整幸运礼物播报条的连刷表现:带右侧中奖奖励框的幸运礼物播报现在会固定在各自的坑位上显示,不再跟随普通礼物滚屏那套纵向位移动画反复“刷新整条”;用户连续送同一条幸运礼物时,前端只会在原位更新右侧 `xN` 数量和累计金币,直到连刷停止后再按既有时长自然消失。
- 已继续微调幸运礼物 `burst` 中央金额文案的定位:此前这层 `+金币` 文字仍使用固定 `top` 绝对定位,实际在不同画布比例下会整体偏上,容易跑出 `luck_gift_reward_burst.svga` 中央的金额承载区;当前已改成基于整层居中后再轻微下移的相对锚点,确保金额文案稳定落在特效中间那块 `+****` 的视觉区域内。
- 已继续细调幸运礼物 `burst` 中央金额文案的纵向位置:上一版相对锚点虽然已经回到特效主体内部,但仍略偏下;当前已把对齐点和下移量一起往中间收回一档,让 `+金币` 更接近 `luck_gift_reward_burst.svga` 中央的视觉中线。
- 已继续修正幸运礼物 `burst` 中央金额文案的细节对位:当前已再上移 `2` 个单位,并给金额文本左侧补出额外留白,避免斜体 `+` 号因为字形外扩而贴边或被裁掉,确保 `+金币` 能完整落在特效中部。
- 已继续按最新联调口径修正幸运礼物 `burst` 中央金额文案:当前已去掉文本里的 `+` 字符,仅保留金币数本身显示;同时维持上一版向上微调后的纵向位置,让金额落点保持在特效中部偏上的稳定区域。
- 已继续收口幸运礼物 `burst` 金额文案与 `svga` 本体的时机同步:此前中央金币文本直接跟外层中奖数据显隐,而 `svga` 自身还存在资源加载和单次播放完成的生命周期,所以两者在出现/消失时会有肉眼可见的前后差;当前已给 `SCSvgaAssetWidget` 补上播放开始/结束回调,并让 `burst` 中央金额只在 `svga` 真正开始播时显示、在它播完清帧时一并隐藏。
- 已优化语言房麦位/头像的二次确认交互:普通用户点击可上麦的空麦位时,当前会直接执行上麦,不再先弹出只有 `Take the mic / Cancel` 的确认层;普通用户点击房间头像或已占麦位上的用户头像时,也会直接打开个人卡片,不再额外弹出仅含 `Open user profile card / Cancel` 的底部确认。房主/管理员仍保留原有带禁麦、锁麦、邀请上麦等管理动作的底部菜单,避免误删管理能力。
- 已继续收窄语言房个人卡片前的“确认意义”弹层:当前用户在麦位上点击自己的头像时,也会直接打开自己的个人卡片,不再先弹出仅包含 `Leave the mic / Open user profile card / Cancel` 的底部菜单;同时个人卡片内的“离开麦位”入口已替换为新的 `leave` 视觉素材,和最新房间交互稿保持一致。
- 已继续微调语言房个人卡片与送礼 UI个人卡片底部动作文案现已支持两行居中展示避免 `Leave the mic` 这类英文按钮被硬截断;房间底部礼物入口也已切换为新的本地 `SVGA` 资源 `room_bottom_gift_button.svga`,保持房间底栏视觉和最新动效稿一致。