自制飞屏动画处理

安卓待验证
This commit is contained in:
roxy 2026-06-03 13:54:43 +08:00
parent 122bc5668d
commit 732e2bfcfb
18 changed files with 873 additions and 277 deletions

View File

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:pag/pag.dart';
import 'package:yumi/shared/data_sources/models/message/sc_floating_message.dart';
import 'package:yumi/ui_kit/widgets/room/floating/floating_lucky_gift_global_screen_widget.dart';
void main() {
runApp(const LuckyGiftGlobalFloatingPreviewApp());
}
class LuckyGiftGlobalFloatingPreviewApp extends StatelessWidget {
const LuckyGiftGlobalFloatingPreviewApp({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
builder: (_, child) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const _LuckyGiftGlobalFloatingPreviewPage(),
);
},
);
}
}
class _LuckyGiftGlobalFloatingPreviewPage extends StatelessWidget {
const _LuckyGiftGlobalFloatingPreviewPage();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: Image.asset(_backgroundAsset, fit: BoxFit.cover),
),
Positioned(
left: 0,
top: 116.w,
child: FloatingLuckyGiftGlobalScreenWidget(
debugDisableAutoSlide: true,
message: SCFloatingMessage(
type: FloatingLuckyGiftGlobalScreenWidget.floatingType,
roomId: '1002900',
userName: 'Bajul',
userAvatarUrl: '',
coins: 1000,
multiple: 10,
),
onAnimationCompleted: () {},
),
),
Positioned(left: 0, top: 250.w, child: const _PagLayerScanner()),
],
),
);
}
}
const _backgroundAsset = 'sc_images/room/background_examples/bg_example_1.png';
class _PagLayerScanner extends StatefulWidget {
const _PagLayerScanner();
@override
State<_PagLayerScanner> createState() => _PagLayerScannerState();
}
class _PagLayerScannerState extends State<_PagLayerScanner> {
final _pagKey = GlobalKey<PAGViewState>();
final _foundLayers = <String>{};
String _summary = 'loading PAG...';
@override
Widget build(BuildContext context) {
return SizedBox(
width: FloatingLuckyGiftGlobalScreenWidget.bannerWidth.w,
height: 160.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: FloatingLuckyGiftGlobalScreenWidget.bannerWidth.w,
height: FloatingLuckyGiftGlobalScreenWidget.bannerHeight.w,
child: PAGView.asset(
FloatingLuckyGiftGlobalScreenWidget.backgroundPagAssetPath,
key: _pagKey,
width: FloatingLuckyGiftGlobalScreenWidget.bannerWidth.w,
height: FloatingLuckyGiftGlobalScreenWidget.bannerHeight.w,
autoPlay: false,
initProgress: 0.35,
repeatCount: PAGView.REPEAT_COUNT_LOOP,
onInit: _scanLayers,
),
),
Container(
width: FloatingLuckyGiftGlobalScreenWidget.bannerWidth.w,
padding: EdgeInsets.all(6.w),
color: Colors.black.withValues(alpha: 0.65),
child: Text(
_summary,
maxLines: 4,
overflow: TextOverflow.ellipsis,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: Colors.white,
fontSize: 9.sp,
decoration: TextDecoration.none,
),
),
),
],
),
);
}
Future<void> _scanLayers() async {
await Future<void>.delayed(const Duration(milliseconds: 200));
final state = _pagKey.currentState;
if (state == null) {
return;
}
final rawWidth = state.rawWidth;
final rawHeight = state.rawHeight;
debugPrint('[LuckyGiftPAG] rawSize=${rawWidth}x$rawHeight');
if (rawWidth <= 0 || rawHeight <= 0) {
return;
}
final sampleXs =
<double>[
40,
80,
120,
170,
220,
280,
340,
410,
480,
560,
640,
710,
].where((value) => value < rawWidth).toList();
final sampleYs =
<double>[
32,
58,
82,
104,
128,
158,
].where((value) => value < rawHeight).toList();
for (final y in sampleYs) {
for (final x in sampleXs) {
final layers = await state.getLayersUnderPoint(x, y);
if (layers.isEmpty) {
continue;
}
_foundLayers.addAll(layers.where((name) => name.trim().isNotEmpty));
debugPrint(
'[LuckyGiftPAG] point=(${x.toStringAsFixed(0)},'
'${y.toStringAsFixed(0)}) layers=$layers',
);
}
}
if (!mounted) {
return;
}
setState(() {
_summary =
'raw ${rawWidth.toStringAsFixed(0)}x'
'${rawHeight.toStringAsFixed(0)} layers: '
'${_foundLayers.join(', ')}';
});
}
}

View File

@ -137,60 +137,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
return 'type=${error.runtimeType} error=$error'; return 'type=${error.runtimeType} error=$error';
} }
void _showLocalLuckyGiftFeedback(
List<MicRes> acceptUsers, {
required SocialChatGiftRes gift,
required int quantity,
required String giftBatchId,
int animationCount = 1,
}) {
final targetUserIds = _resolveAcceptUserIds(acceptUsers);
final rtmProvider = Provider.of<RtmProvider>(
navigatorKey.currentState!.context,
listen: false,
);
final currentUser = AccountStorage().getCurrentUser()?.userProfile;
if (currentUser == null) {
_giftFxLog(
'local lucky feedback skipped reason=no_current_user giftId=${gift.id}',
);
return;
}
for (final acceptUser in acceptUsers) {
final targetUser = acceptUser.user;
if (targetUser == null) {
_giftFxLog(
'local lucky feedback skipped reason=no_target_user giftId=${gift.id}',
);
continue;
}
_giftFxLog(
'local lucky feedback trigger '
'giftId=${gift.id} '
'giftName=${gift.giftName} '
'toUserId=${targetUser.id} '
'toUserName=${targetUser.userNickname} '
'quantity=$quantity',
);
rtmProvider.msgFloatingGiftListener?.call(
Msg(
groupId:
rtcProvider?.currenRoom?.roomProfile?.roomProfile?.roomAccount,
msg: rtcProvider?.currenRoom?.roomProfile?.roomProfile?.id ?? "",
type: SCRoomMsgType.luckGiftAnimOther,
gift: gift,
user: currentUser,
toUser: targetUser,
targetUserIds: targetUserIds,
giftBatchId: giftBatchId,
number: quantity,
customAnimationCount: animationCount,
),
);
}
}
void _applyGiftSelection(SocialChatGiftRes? gift, {bool notify = true}) { void _applyGiftSelection(SocialChatGiftRes? gift, {bool notify = true}) {
checkedGift = gift; checkedGift = gift;
if (gift != null && if (gift != null &&
@ -1602,13 +1548,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
_showLocalCpInviteWaitingAfterGift(request, request.acceptUsers); _showLocalCpInviteWaitingAfterGift(request, request.acceptUsers);
if (request.isLuckyGiftRequest) { if (request.isLuckyGiftRequest) {
final giftBatchId = _createGiftBatchId(request); final giftBatchId = _createGiftBatchId(request);
_showLocalLuckyGiftFeedback(
request.acceptUsers,
gift: request.gift,
quantity: request.quantity,
giftBatchId: giftBatchId,
animationCount: request.clickCount,
);
await sendLuckGiftAnimOtherMsg( await sendLuckGiftAnimOtherMsg(
request.acceptUsers, request.acceptUsers,
gift: request.gift, gift: request.gift,
@ -2127,7 +2066,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
required String giftBatchId, required String giftBatchId,
int animationCount = 1, int animationCount = 1,
}) async { }) async {
final targetUserIds = _resolveAcceptUserIds(acceptUsers); final targetUserIds = _resolveAcceptUserIdentityKeys(acceptUsers);
final firstTargetUser = final firstTargetUser =
acceptUsers.isNotEmpty ? acceptUsers.first.user : null; acceptUsers.isNotEmpty ? acceptUsers.first.user : null;
_giftFxLog( _giftFxLog(
@ -2156,7 +2095,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
number: quantity, number: quantity,
customAnimationCount: animationCount, customAnimationCount: animationCount,
type: SCRoomMsgType.luckGiftAnimOther, type: SCRoomMsgType.luckGiftAnimOther,
msg: jsonEncode(acceptUsers.map((u) => u.user?.id).toList()), msg: jsonEncode(targetUserIds),
), ),
addLocal: true, addLocal: true,
); );
@ -2179,6 +2118,26 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
return targetUserIds; return targetUserIds;
} }
List<String> _resolveAcceptUserIdentityKeys(List<MicRes> acceptUsers) {
final targetUserIds = <String>[];
void addTargetUserId(String? userId) {
final normalizedUserId = (userId ?? "").trim();
if (normalizedUserId.isEmpty ||
targetUserIds.contains(normalizedUserId)) {
return;
}
targetUserIds.add(normalizedUserId);
}
for (final acceptUser in acceptUsers) {
final user = acceptUser.user;
addTargetUserId(user?.id);
addTargetUserId(user?.account);
addTargetUserId(user?.getID());
}
return targetUserIds;
}
String _createGiftBatchId(_GiftSendRequest request) { String _createGiftBatchId(_GiftSendRequest request) {
final sortedAcceptUserIds = List<String>.from(request.acceptUserIds) final sortedAcceptUserIds = List<String>.from(request.acceptUserIds)
..sort(); ..sort();

View File

@ -175,6 +175,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
void _handleRoomRouteVisible() { void _handleRoomRouteVisible() {
_releaseCoveredRouteGuard(); _releaseCoveredRouteGuard();
_setRoomRouteVisible(true); _setRoomRouteVisible(true);
_scheduleEnsureRoomVisualEffectsEnabled();
} }
void _scheduleRoomRouteVisible() { void _scheduleRoomRouteVisible() {
@ -249,13 +250,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
rtmProvider.msgFloatingGiftListener = _floatingGiftListener; rtmProvider.msgFloatingGiftListener = _floatingGiftListener;
rtmProvider.msgLuckyGiftRewardTickerListener = rtmProvider.msgLuckyGiftRewardTickerListener =
_luckyGiftRewardTickerListener; _luckyGiftRewardTickerListener;
debugPrint('[GiftFx][voice] bind room visual effect listeners');
} }
void _ensureRoomVisualEffectsEnabled() { void _ensureRoomVisualEffectsEnabled() {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false); final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
if (rtcProvider.currenRoom == null || if (rtcProvider.currenRoom == null || !_roomRouteVisible) {
!_roomRouteVisible || return;
rtcProvider.roomVisualEffectsEnabled) { }
final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
final needsListenerRebind =
rtmProvider.msgFloatingGiftListener != _floatingGiftListener ||
rtmProvider.msgLuckyGiftRewardTickerListener !=
_luckyGiftRewardTickerListener;
if (rtcProvider.roomVisualEffectsEnabled && !needsListenerRebind) {
return; return;
} }
_enableRoomVisualEffects(); _enableRoomVisualEffects();
@ -684,13 +692,36 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_floatingGiftListener(Msg msg) { _floatingGiftListener(Msg msg) {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false); final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
if (!rtcProvider.shouldShowRoomVisualEffects) { if (!rtcProvider.shouldShowRoomVisualEffects) {
debugPrint(
'[GiftFx][voice] skip floating listener visual effects hidden '
'type=${msg.type} '
'giftId=${msg.gift?.id} '
'enabled=${rtcProvider.roomVisualEffectsEnabled} '
'visible=${rtcProvider.isVoiceRoomRouteVisible} '
'hasRoom=${rtcProvider.currenRoom != null}',
);
return; return;
} }
if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) { if (Provider.of<GiftProvider>(context, listen: false).hideLGiftAnimal) {
debugPrint(
'[GiftFx][voice] skip floating listener hidden by gift preference '
'type=${msg.type} giftId=${msg.gift?.id}',
);
return; return;
} }
final targetUserIds = _resolveGiftTargetUserIds(msg); final targetUserIds = _resolveGiftTargetUserIds(msg);
final targetGroupKey = _buildGiftTargetGroupKey(targetUserIds); final targetGroupKey = _buildGiftTargetGroupKey(targetUserIds);
debugPrint(
'[GiftFx][voice] floating listener recv '
'type=${msg.type} '
'giftId=${msg.gift?.id} '
'giftTab=${msg.gift?.giftTab} '
'quantity=${msg.number} '
'animationCount=${msg.customAnimationCount} '
'giftPhoto=${msg.gift?.giftPhoto} '
'targetUserIds=${targetUserIds.join(",")} '
'targetGroupKey=$targetGroupKey',
);
final shouldPlayBatchVisuals = final shouldPlayBatchVisuals =
targetUserIds.length <= 1 || targetUserIds.length <= 1 ||
targetGroupKey == null || targetGroupKey == null ||
@ -726,7 +757,10 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
listen: false, listen: false,
).enqueueGiftAnimation(giftModel); ).enqueueGiftAnimation(giftModel);
} else { } else {
return; debugPrint(
'[GiftFx][voice] skip gift ticker by visual batch dedupe '
'type=${msg.type} giftId=${msg.gift?.id} targetGroupKey=$targetGroupKey',
);
} }
final giftPhoto = (msg.gift?.giftPhoto ?? "").trim(); final giftPhoto = (msg.gift?.giftPhoto ?? "").trim();
@ -861,7 +895,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
bool _isLuckyGiftMessage(Msg msg) { bool _isLuckyGiftMessage(Msg msg) {
final giftTab = (msg.gift?.giftTab ?? '').trim(); final giftTab = (msg.gift?.giftTab ?? '').trim();
return giftTab == "LUCK" || giftTab == SCGiftType.LUCKY_GIFT.name; return giftTab == "LUCK" ||
giftTab == SCGiftType.LUCKY_GIFT.name ||
giftTab == SCGiftType.MAGIC.name;
} }
bool _supportsComboMilestoneEffects(Msg msg) { bool _supportsComboMilestoneEffects(Msg msg) {
@ -956,26 +992,46 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
List<String> targetUserIds, List<String> targetUserIds,
) { ) {
if ((msg.number ?? 0) <= 0) { if ((msg.number ?? 0) <= 0) {
debugPrint(
'[GiftFx][voice] skip lucky seat flight invalid quantity '
'giftId=${msg.gift?.id} quantity=${msg.number}',
);
return; return;
} }
if (targetGroupKey == null || targetUserIds.isEmpty) { if (targetGroupKey == null || targetUserIds.isEmpty) {
debugPrint(
'[GiftFx][voice] skip lucky seat flight empty targets '
'giftId=${msg.gift?.id} targetGroupKey=$targetGroupKey',
);
return; return;
} }
final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey); final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey);
final session = _luckyGiftComboSessions[sessionKey]; final session = _luckyGiftComboSessions.putIfAbsent(
if (session == null) { sessionKey,
return; () => _LuckyGiftComboSession(),
} );
session.endTimer?.cancel(); session.endTimer?.cancel();
session.clearQueueTimer?.cancel(); session.clearQueueTimer?.cancel();
if (_shouldPlaySeatFlightGiftAnimation(msg) && giftPhoto.isNotEmpty) { if (_shouldPlayLuckyGiftSeatFlightAnimation(msg) && giftPhoto.isNotEmpty) {
final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds); final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds);
if (targetUserIds.length > 1 && if (targetUserIds.length > 1 &&
!_markGiftFlightBatchForPlayback(flightBatchKey)) { !_markGiftFlightBatchForPlayback(flightBatchKey)) {
debugPrint(
'[GiftFx][voice] skip lucky seat flight by flight batch dedupe '
'giftId=${msg.gift?.id} batchKey=$flightBatchKey',
);
_scheduleGiftAnimationSessionEnd(sessionKey); _scheduleGiftAnimationSessionEnd(sessionKey);
return; return;
} }
debugPrint(
'[GiftFx][voice] enqueue lucky seat flight '
'giftId=${msg.gift?.id} '
'giftPhoto=$giftPhoto '
'targetUserIds=${targetUserIds.join(",")} '
'animationCount=${_resolveTrackedAnimationCount(msg)} '
'batchKey=$flightBatchKey',
);
_enqueueTrackedSeatFlightAnimations( _enqueueTrackedSeatFlightAnimations(
sessionKey: sessionKey, sessionKey: sessionKey,
giftPhoto: giftPhoto, giftPhoto: giftPhoto,
@ -983,10 +1039,29 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
animationCount: _resolveTrackedAnimationCount(msg), animationCount: _resolveTrackedAnimationCount(msg),
batchKey: flightBatchKey, batchKey: flightBatchKey,
); );
} else {
debugPrint(
'[GiftFx][voice] skip lucky seat flight unsupported gift photo '
'giftId=${msg.gift?.id} giftPhoto=$giftPhoto',
);
} }
_scheduleGiftAnimationSessionEnd(sessionKey); _scheduleGiftAnimationSessionEnd(sessionKey);
} }
bool _shouldPlayLuckyGiftSeatFlightAnimation(Msg msg) {
final gift = msg.gift;
if (gift == null) {
return false;
}
final giftPhoto = (gift.giftPhoto ?? "").trim();
if (giftPhoto.isEmpty) {
return false;
}
final giftPhotoExt = _normalizedGiftResourceExtension(giftPhoto);
return !_isAnimatedGiftResource(giftPhotoExt);
}
int _resolveTrackedAnimationCount(Msg msg) { int _resolveTrackedAnimationCount(Msg msg) {
return math.max(msg.customAnimationCount ?? 1, 1); return math.max(msg.customAnimationCount ?? 1, 1);
} }
@ -1016,21 +1091,51 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
); );
final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds); final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds);
if (normalizedTargetUserIds.isEmpty) { if (normalizedTargetUserIds.isEmpty) {
debugPrint(
'[GiftFx][voice] skip seat flight enqueue empty normalized targets '
'sessionKey=$sessionKey',
);
return; return;
} }
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final seenSeatKeys = <GlobalKey<State<StatefulWidget>>>{};
final resolvedTargetUserIds = <String>[];
final unresolvedTargetUserIds = <String>[];
for (final targetUserId in normalizedTargetUserIds) {
final targetKey = rtcProvider.getSeatGlobalKeyByIndex(targetUserId);
if (targetKey == null) {
unresolvedTargetUserIds.add(targetUserId);
continue;
}
if (seenSeatKeys.add(targetKey)) {
resolvedTargetUserIds.add(targetUserId);
}
}
final flightTargetUserIds =
resolvedTargetUserIds.isNotEmpty
? resolvedTargetUserIds
: normalizedTargetUserIds;
debugPrint(
'[GiftFx][voice] seat flight targets '
'sessionKey=$sessionKey '
'requested=${normalizedTargetUserIds.join(",")} '
'resolved=${resolvedTargetUserIds.join(",")} '
'unresolved=${unresolvedTargetUserIds.join(",")} '
'used=${flightTargetUserIds.join(",")}',
);
final normalizedAnimationCount = math.max(animationCount, 1); final normalizedAnimationCount = math.max(animationCount, 1);
final cappedAnimationCount = math.min( final cappedAnimationCount = math.min(
normalizedAnimationCount, normalizedAnimationCount,
_maxTrackedGiftAnimations, _maxTrackedGiftAnimations,
); );
final maxTrackedRequests = math.max( final maxTrackedRequests = math.max(
_maxTrackedGiftAnimations * normalizedTargetUserIds.length, _maxTrackedGiftAnimations * flightTargetUserIds.length,
_maxTrackedGiftAnimations, _maxTrackedGiftAnimations,
); );
for (var index = 0; index < cappedAnimationCount; index += 1) { for (var index = 0; index < cappedAnimationCount; index += 1) {
final roundBatchTag = final roundBatchTag =
normalizedTargetUserIds.length > 1 ? '$batchKey|round:$index' : null; flightTargetUserIds.length > 1 ? '$batchKey|round:$index' : null;
for (final targetUserId in normalizedTargetUserIds) { for (final targetUserId in flightTargetUserIds) {
_giftSeatFlightController.enqueueLimited( _giftSeatFlightController.enqueueLimited(
RoomGiftSeatFlightRequest( RoomGiftSeatFlightRequest(
imagePath: giftPhoto, imagePath: giftPhoto,

View File

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room_game/bridge/baishun_js_bridge.dart'; import 'package:yumi/modules/room_game/bridge/baishun_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
@ -50,6 +51,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
bool _didReceiveBridgeMessage = false; bool _didReceiveBridgeMessage = false;
bool _didFinishPageLoad = false; bool _didFinishPageLoad = false;
bool _hasDeliveredLaunchConfig = false; bool _hasDeliveredLaunchConfig = false;
bool _shouldMountWebView = defaultTargetPlatform != TargetPlatform.android;
int _bridgeInjectCount = 0; int _bridgeInjectCount = 0;
String? _errorMessage; String? _errorMessage;
@ -105,6 +107,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
onPageFinished: (String url) async { onPageFinished: (String url) async {
_didFinishPageLoad = true; _didFinishPageLoad = true;
_log('page_finished url=${_clip(url, 240)}'); _log('page_finished url=${_clip(url, 240)}');
_showWebView(reason: 'page_finished');
await _injectBridge(reason: 'page_finished'); await _injectBridge(reason: 'page_finished');
}, },
onWebResourceError: (WebResourceError error) { onWebResourceError: (WebResourceError error) {
@ -142,6 +145,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
_didReceiveBridgeMessage = false; _didReceiveBridgeMessage = false;
_didFinishPageLoad = false; _didFinishPageLoad = false;
_hasDeliveredLaunchConfig = false; _hasDeliveredLaunchConfig = false;
_shouldMountWebView = defaultTargetPlatform != TargetPlatform.android;
_bridgeInjectCount = 0; _bridgeInjectCount = 0;
_stopBridgeBootstrap(reason: 'prepare_page_load'); _stopBridgeBootstrap(reason: 'prepare_page_load');
_bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), ( _bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), (
@ -173,6 +177,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
return; return;
} }
_log('loading_fallback_fire isLoading=$_isLoading'); _log('loading_fallback_fire isLoading=$_isLoading');
_showWebView(reason: 'loading_fallback');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
@ -326,6 +331,7 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
return; return;
} }
_log('game_loaded received'); _log('game_loaded received');
_showWebView(reason: 'game_loaded');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_errorMessage = null; _errorMessage = null;
@ -357,6 +363,16 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
} }
} }
void _showWebView({required String reason}) {
if (_shouldMountWebView || !mounted) {
return;
}
_log('mount_webview reason=$reason');
setState(() {
_shouldMountWebView = true;
});
}
Future<void> _reload() async { Future<void> _reload() async {
if (!mounted) { if (!mounted) {
return; return;
@ -412,7 +428,11 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
return ''; return '';
} }
void _log(String message) {} void _log(String message) {
if (kDebugMode) {
debugPrint('$_logPrefix $message');
}
}
String _clip(String value, [int limit = 600]) { String _clip(String value, [int limit = 600]) {
final trimmed = value.trim(); final trimmed = value.trim();
@ -631,6 +651,26 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
return screenSize.width / ratio; return screenSize.width / ratio;
} }
Widget _buildWebView() {
final params = PlatformWebViewWidgetCreationParams(
controller: _controller.platform,
gestureRecognizers: _webGestureRecognizers,
);
if (defaultTargetPlatform == TargetPlatform.android) {
return WebViewWidget.fromPlatformCreationParams(
params:
AndroidWebViewWidgetCreationParams.fromPlatformWebViewWidgetCreationParams(
params,
displayWithHybridComposition: true,
),
);
}
return WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final topCrop = 28.w; final topCrop = 28.w;
@ -660,16 +700,14 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
color: const Color(0xFF081915), color: const Color(0xFF081915),
child: Stack( child: Stack(
children: [ children: [
Positioned( if (_shouldMountWebView)
top: -topCrop, Positioned(
left: 0, top: -topCrop,
right: 0, left: 0,
bottom: bottomFrameHeight, right: 0,
child: WebViewWidget( bottom: bottomFrameHeight,
controller: _controller, child: _buildWebView(),
gestureRecognizers: _webGestureRecognizers,
), ),
),
if (bottomFrameHeight > 0) if (bottomFrameHeight > 0)
Positioned( Positioned(
left: 0, left: 0,
@ -682,10 +720,12 @@ class _BaishunGamePageState extends State<BaishunGamePage> {
), ),
if (_errorMessage != null) _buildErrorState(), if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null) if (_isLoading && _errorMessage == null)
const IgnorePointer( const Positioned.fill(
ignoring: true, child: IgnorePointer(
child: BaishunLoadingView( ignoring: true,
message: 'Waiting for gameLoaded...', child: BaishunLoadingView(
message: 'Waiting for gameLoaded...',
),
), ),
), ),
], ],

View File

@ -14,7 +14,7 @@ class BaishunLoadingView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Positioned.fill( return SizedBox.expand(
child: Container( child: Container(
color: backgroundColor, color: backgroundColor,
alignment: Alignment.center, alignment: Alignment.center,

View File

@ -6,6 +6,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/modules/room_game/bridge/leader_js_bridge.dart'; import 'package:yumi/modules/room_game/bridge/leader_js_bridge.dart';
import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart';
@ -50,6 +51,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
bool _isClosing = false; bool _isClosing = false;
bool _didReceiveBridgeMessage = false; bool _didReceiveBridgeMessage = false;
bool _didFinishPageLoad = false; bool _didFinishPageLoad = false;
bool _shouldMountWebView = defaultTargetPlatform != TargetPlatform.android;
int _bridgeInjectCount = 0; int _bridgeInjectCount = 0;
String? _errorMessage; String? _errorMessage;
@ -105,6 +107,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
onPageFinished: (String url) async { onPageFinished: (String url) async {
_didFinishPageLoad = true; _didFinishPageLoad = true;
_log('page_finished url=${_clip(url, 240)}'); _log('page_finished url=${_clip(url, 240)}');
_showWebView(reason: 'page_finished');
await _injectBridge(reason: 'page_finished'); await _injectBridge(reason: 'page_finished');
}, },
onWebResourceError: (WebResourceError error) { onWebResourceError: (WebResourceError error) {
@ -136,6 +139,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
void _prepareForPageLoad() { void _prepareForPageLoad() {
_didReceiveBridgeMessage = false; _didReceiveBridgeMessage = false;
_didFinishPageLoad = false; _didFinishPageLoad = false;
_shouldMountWebView = defaultTargetPlatform != TargetPlatform.android;
_bridgeInjectCount = 0; _bridgeInjectCount = 0;
_stopBridgeBootstrap(reason: 'prepare_page_load'); _stopBridgeBootstrap(reason: 'prepare_page_load');
_bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), ( _bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), (
@ -155,6 +159,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
return; return;
} }
_log('loading_fallback_fire'); _log('loading_fallback_fire');
_showWebView(reason: 'loading_fallback');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
@ -274,6 +279,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
if (!mounted) { if (!mounted) {
return; return;
} }
_showWebView(reason: 'load_complete');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_errorMessage = null; _errorMessage = null;
@ -294,6 +300,16 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
} }
} }
void _showWebView({required String reason}) {
if (_shouldMountWebView || !mounted) {
return;
}
_log('mount_webview reason=$reason');
setState(() {
_shouldMountWebView = true;
});
}
Future<void> _reload() async { Future<void> _reload() async {
if (!mounted) { if (!mounted) {
return; return;
@ -385,7 +401,31 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
return fallback; return fallback;
} }
void _log(String message) {} Widget _buildWebView() {
final params = PlatformWebViewWidgetCreationParams(
controller: _controller.platform,
gestureRecognizers: _webGestureRecognizers,
);
if (defaultTargetPlatform == TargetPlatform.android) {
return WebViewWidget.fromPlatformCreationParams(
params:
AndroidWebViewWidgetCreationParams.fromPlatformWebViewWidgetCreationParams(
params,
displayWithHybridComposition: true,
),
);
}
return WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
);
}
void _log(String message) {
if (kDebugMode) {
debugPrint('$_logPrefix $message');
}
}
String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) { String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) {
final trimmed = value.trim(); final trimmed = value.trim();
@ -566,16 +606,14 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
color: const Color(0xFF081915), color: const Color(0xFF081915),
child: Stack( child: Stack(
children: [ children: [
Positioned( if (_shouldMountWebView)
left: 0, Positioned(
right: 0, left: 0,
top: 0, right: 0,
bottom: bottomFrameHeight, top: 0,
child: WebViewWidget( bottom: bottomFrameHeight,
controller: _controller, child: _buildWebView(),
gestureRecognizers: _webGestureRecognizers,
), ),
),
if (bottomFrameHeight > 0) if (bottomFrameHeight > 0)
Positioned( Positioned(
left: 0, left: 0,
@ -588,10 +626,12 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
), ),
if (_errorMessage != null) _buildErrorState(), if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null) if (_isLoading && _errorMessage == null)
const IgnorePointer( const Positioned.fill(
ignoring: true, child: IgnorePointer(
child: BaishunLoadingView( ignoring: true,
message: 'Waiting for Leader game...', child: BaishunLoadingView(
message: 'Waiting for Leader game...',
),
), ),
), ),
], ],

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -192,7 +193,11 @@ class _RoomGameListSheetState extends State<RoomGameListSheet> {
} }
} }
void _log(String message) {} void _log(String message) {
if (kDebugMode) {
debugPrint('$_logPrefix $message');
}
}
String _clip(String value, [int limit = 600]) { String _clip(String value, [int limit = 600]) {
final trimmed = value.trim(); final trimmed = value.trim();

View File

@ -51,6 +51,8 @@ import '../../shared/data_sources/models/enum/sc_heartbeat_status.dart';
import '../../shared/data_sources/models/enum/sc_room_info_event_type.dart'; import '../../shared/data_sources/models/enum/sc_room_info_event_type.dart';
import '../../shared/data_sources/models/enum/sc_room_roles_type.dart'; import '../../shared/data_sources/models/enum/sc_room_roles_type.dart';
import '../../shared/business_logic/models/res/sc_is_follow_room_res.dart'; import '../../shared/business_logic/models/res/sc_is_follow_room_res.dart';
import '../../shared/business_logic/models/res/sc_mic_go_up_res.dart'
hide PropsResources;
import '../../shared/business_logic/models/res/sc_room_rocket_api_res.dart'; import '../../shared/business_logic/models/res/sc_room_rocket_api_res.dart';
import '../../shared/business_logic/models/res/sc_room_red_packet_config_res.dart'; import '../../shared/business_logic/models/res/sc_room_red_packet_config_res.dart';
import '../../shared/business_logic/models/res/sc_room_red_packet_list_res.dart'; import '../../shared/business_logic/models/res/sc_room_red_packet_list_res.dart';
@ -523,13 +525,19 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Set<String> _currentUserIdentityKeys() { Set<String> _currentUserIdentityKeys() {
final currentUser = AccountStorage().getCurrentUser()?.userProfile; final currentUser = AccountStorage().getCurrentUser()?.userProfile;
return {currentUser?.id?.trim() ?? "", currentUser?.account?.trim() ?? ""} return {
..removeWhere((item) => item.isEmpty); currentUser?.id?.trim() ?? "",
currentUser?.account?.trim() ?? "",
currentUser?.getID().trim() ?? "",
}..removeWhere((item) => item.isEmpty);
} }
Set<String> _userProfileIdentityKeys(SocialChatUserProfile? user) { Set<String> _userProfileIdentityKeys(SocialChatUserProfile? user) {
return {user?.id?.trim() ?? "", user?.account?.trim() ?? ""} return {
..removeWhere((item) => item.isEmpty); user?.id?.trim() ?? "",
user?.account?.trim() ?? "",
user?.getID().trim() ?? "",
}..removeWhere((item) => item.isEmpty);
} }
bool _userProfileMatchesAnyIdentity( bool _userProfileMatchesAnyIdentity(
@ -1564,8 +1572,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
} }
void _syncSelfMicRuntimeState() { void _syncSelfMicRuntimeState() {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id; final currentUserKeys = _currentUserIdentityKeys();
if ((currentUserId ?? "").isEmpty) { if (currentUserKeys.isEmpty) {
return; return;
} }
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
@ -2417,6 +2425,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
bool keepAudioPublishing = false, bool keepAudioPublishing = false,
}) async { }) async {
if (!_roomRtcJoined || _roomRtcEngineAdapter == null) { if (!_roomRtcJoined || _roomRtcEngineAdapter == null) {
debugPrint(
'[RoomRtc] switchToBroadcaster skipped joined=$_roomRtcJoined '
'hasAdapter=${_roomRtcEngineAdapter != null}',
);
return false; return false;
} }
try { try {
@ -2425,6 +2437,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
RoomRtcAnchorConfig(muted: muted, publishMuted: localAudioMuted), RoomRtcAnchorConfig(muted: muted, publishMuted: localAudioMuted),
); );
if (!switched) { if (!switched) {
debugPrint('[RoomRtc] switchToBroadcaster failed adapter=false');
_roomRtcRoleIsBroadcaster = false; _roomRtcRoleIsBroadcaster = false;
_resyncHeartbeatFromRoomRtcState(); _resyncHeartbeatFromRoomRtcState();
return false; return false;
@ -2437,6 +2450,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_resyncHeartbeatFromRoomRtcState(); _resyncHeartbeatFromRoomRtcState();
return true; return true;
} catch (error) { } catch (error) {
debugPrint('[RoomRtc] switchToBroadcaster error=$error');
_roomRtcRoleIsBroadcaster = false; _roomRtcRoleIsBroadcaster = false;
_resyncHeartbeatFromRoomRtcState(); _resyncHeartbeatFromRoomRtcState();
return false; return false;
@ -5198,14 +5212,63 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (normalizedUserId.isEmpty) { if (normalizedUserId.isEmpty) {
return; return;
} }
final lookupKeys = <String>{normalizedUserId};
final currentUserKeys = _currentUserIdentityKeys();
if (currentUserKeys.contains(normalizedUserId)) {
lookupKeys.addAll(currentUserKeys);
}
roomWheatMap.forEach((seatIndex, seat) { roomWheatMap.forEach((seatIndex, seat) {
if (seat.user?.id != normalizedUserId || seatIndex == exceptIndex) { final seatUserKeys = _userProfileIdentityKeys(seat.user);
if (!seatUserKeys.any(lookupKeys.contains) || seatIndex == exceptIndex) {
return; return;
} }
roomWheatMap[seatIndex] = seat.copyWith(clearUser: true); roomWheatMap[seatIndex] = seat.copyWith(clearUser: true);
}); });
} }
void _applySelfMicGoUpState({
required SocialChatUserProfile myUser,
required num targetIndex,
required SCMicGoUpRes micGoUpRes,
required bool selfMicMuted,
}) {
final previousSelfSeatIndex = userOnMaiInIndex(myUser.id ?? "");
final isSeatSwitching =
previousSelfSeatIndex > -1 && previousSelfSeatIndex != targetIndex;
_clearSelfMicReleaseGuard();
if (isSeatSwitching) {
_startSelfMicSwitchGuard(
sourceIndex: previousSelfSeatIndex,
targetIndex: targetIndex,
);
} else {
_clearSelfMicSwitchGuard();
_startSelfMicGoUpGuard(targetIndex: targetIndex);
}
_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,
);
isMic = selfMicMuted;
_syncSelfMicRuntimeState();
}
void _openRoomUserInfoCard(String? userId) { void _openRoomUserInfoCard(String? userId) {
final normalizedUserId = (userId ?? '').trim(); final normalizedUserId = (userId ?? '').trim();
if (normalizedUserId.isEmpty) { if (normalizedUserId.isEmpty) {
@ -5269,66 +5332,29 @@ class RealTimeCommunicationManager extends ChangeNotifier {
final targetIndex = micGoUpRes.micIndex ?? index; final targetIndex = micGoUpRes.micIndex ?? index;
final selfMicMuted = micGoUpRes.micMute ?? false; final selfMicMuted = micGoUpRes.micMute ?? false;
if (myUser != null) {
_applySelfMicGoUpState(
myUser: myUser,
targetIndex: targetIndex,
micGoUpRes: micGoUpRes,
selfMicMuted: selfMicMuted,
);
notifyListeners();
}
final switched = await _switchRoomRtcToBroadcaster( final switched = await _switchRoomRtcToBroadcaster(
muted: selfMicMuted, muted: selfMicMuted,
keepAudioPublishing: selfMicMuted && _currentUserPublishingRoomMusic, keepAudioPublishing: selfMicMuted && _currentUserPublishingRoomMusic,
); );
if (!switched) { if (!switched) {
await _switchRoomRtcToAudience();
try {
await SCChatRoomRepository().micGoDown(
currenRoom?.roomProfile?.roomProfile?.id ?? "",
targetIndex,
);
} catch (downError) {}
_clearSelfMicSwitchGuard(clearPreferredIndex: true);
_clearSelfMicGoUpGuard(clearPreferredIndex: true);
_clearUserFromSeats(myUser?.id);
_syncSelfMicRuntimeState();
notifyListeners();
_refreshMicListSilently(notifyIfUnchanged: true); _refreshMicListSilently(notifyIfUnchanged: true);
_handleSelfMicGoUpFailure( _resetSelfMicGoUpFailureState();
'Failed to put on the microphone, TRTC switch failed', SCTts.show(
'Microphone audio failed to start, please try switching microphone again',
); );
return; return;
} }
final previousSelfSeatIndex =
myUser != null ? userOnMaiInIndex(myUser.id ?? "") : -1;
final isSeatSwitching =
previousSelfSeatIndex > -1 && previousSelfSeatIndex != targetIndex;
if (myUser != null) {
_clearSelfMicReleaseGuard();
if (isSeatSwitching) {
_startSelfMicSwitchGuard(
sourceIndex: previousSelfSeatIndex,
targetIndex: targetIndex,
);
} else {
_clearSelfMicSwitchGuard();
_startSelfMicGoUpGuard(targetIndex: targetIndex);
}
_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,
);
}
isMic = selfMicMuted;
_syncSelfMicRuntimeState();
if (roomWheatMap[targetIndex]?.micMute ?? false) { if (roomWheatMap[targetIndex]?.micMute ?? false) {
/// ///
if (isFz()) { if (isFz()) {
@ -5477,6 +5503,13 @@ class RealTimeCommunicationManager extends ChangeNotifier {
/// ///
num userOnMaiInIndex(String userId) { num userOnMaiInIndex(String userId) {
return _findUserOnMaiInIndex(userId, useDisplaySeatForCurrentUser: true);
}
num _findUserOnMaiInIndex(
String userId, {
required bool useDisplaySeatForCurrentUser,
}) {
final normalizedUserId = userId.trim(); final normalizedUserId = userId.trim();
if (normalizedUserId.isEmpty) { if (normalizedUserId.isEmpty) {
return -1; return -1;
@ -5484,12 +5517,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
num index = -1; num index = -1;
final currentUserKeys = _currentUserIdentityKeys(); final currentUserKeys = _currentUserIdentityKeys();
final isCurrentUserQuery = currentUserKeys.contains(normalizedUserId); final isCurrentUserQuery = currentUserKeys.contains(normalizedUserId);
final lookupKeys = final lookupKeys = <String>{normalizedUserId};
isCurrentUserQuery ? currentUserKeys : <String>{normalizedUserId} if (isCurrentUserQuery) {
..removeWhere((item) => item.isEmpty); lookupKeys.addAll(currentUserKeys);
}
lookupKeys.removeWhere((item) => item.isEmpty);
roomWheatMap.forEach((k, value) { roomWheatMap.forEach((k, value) {
final visibleSeat = isCurrentUserQuery ? micAtIndexForDisplay(k) : value; final candidateSeat =
if (_userProfileMatchesAnyIdentity(visibleSeat?.user, lookupKeys)) { useDisplaySeatForCurrentUser && isCurrentUserQuery
? micAtIndexForDisplay(k)
: value;
if (_userProfileMatchesAnyIdentity(candidateSeat?.user, lookupKeys)) {
index = k; index = k;
} }
}); });
@ -5780,7 +5818,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
GlobalKey<State<StatefulWidget>>? getSeatGlobalKeyByIndex(String userId) { GlobalKey<State<StatefulWidget>>? getSeatGlobalKeyByIndex(String userId) {
/// ///
num index = userOnMaiInIndex(userId); num index = _findUserOnMaiInIndex(
userId,
useDisplaySeatForCurrentUser: false,
);
if (index > -1) { if (index > -1) {
return seatGlobalKeyMap[index]; return seatGlobalKeyMap[index];
} }

View File

@ -276,6 +276,33 @@ class RealTimeMessagingManager extends ChangeNotifier {
].join("|"); ].join("|");
} }
bool _shouldNotifyFloatingGiftListener(Msg msg) {
if (msg.type == SCRoomMsgType.gift) {
return true;
}
if (msg.type == SCRoomMsgType.luckGiftAnimOther) {
return msg.user != null && msg.gift != null;
}
return false;
}
void _notifyFloatingGiftListenerFromAddMsg(Msg msg) {
if (!_shouldNotifyFloatingGiftListener(msg)) {
return;
}
_giftFxLog(
'trigger floating gift listener from addMsg '
'type=${msg.type} '
'giftId=${msg.gift?.id} '
'sendUserId=${msg.user?.id} '
'toUserId=${msg.toUser?.id} '
'quantity=${msg.number} '
'targetUserIds=${(msg.targetUserIds ?? const <String>[]).join(",")} '
'hasListener=${msgFloatingGiftListener != null}',
);
msgFloatingGiftListener?.call(msg);
}
String _firstNonBlank(Iterable<String?> values) { String _firstNonBlank(Iterable<String?> values) {
for (final value in values) { for (final value in values) {
final text = value?.trim(); final text = value?.trim();
@ -2977,9 +3004,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (mergedGiftMsg != null) { if (mergedGiftMsg != null) {
msgAllListener?.call(mergedGiftMsg); msgAllListener?.call(mergedGiftMsg);
msgGiftListener?.call(mergedGiftMsg); msgGiftListener?.call(mergedGiftMsg);
if (msg.type == SCRoomMsgType.gift) { _notifyFloatingGiftListenerFromAddMsg(msg);
msgFloatingGiftListener?.call(msg);
}
notifyListeners(); notifyListeners();
return; return;
} }
@ -3071,12 +3096,41 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomGiftMsgList.removeAt(roomGiftMsgList.length - 1); roomGiftMsgList.removeAt(roomGiftMsgList.length - 1);
} }
msgGiftListener?.call(msg); msgGiftListener?.call(msg);
if (msg.type == SCRoomMsgType.gift) { _notifyFloatingGiftListenerFromAddMsg(msg);
msgFloatingGiftListener?.call(msg);
}
} }
} }
List<String> _luckGiftTargetUserIdsFromMsg(Msg msg) {
final targetUserIds = <String>[];
void addTargetUserId(dynamic userId) {
final normalizedUserId = userId?.toString().trim() ?? '';
if (normalizedUserId.isEmpty ||
targetUserIds.contains(normalizedUserId)) {
return;
}
targetUserIds.add(normalizedUserId);
}
for (final userId in msg.targetUserIds ?? const <String>[]) {
addTargetUserId(userId);
}
final rawTargetUserIds = (msg.msg ?? '').trim();
if (rawTargetUserIds.startsWith('[')) {
try {
final decoded = jsonDecode(rawTargetUserIds);
if (decoded is List) {
for (final userId in decoded) {
addTargetUserId(userId);
}
}
} catch (_) {}
}
addTargetUserId(msg.toUser?.id);
return targetUserIds;
}
void _enqueueCpRelationBroadcastFloatingIfNeeded( void _enqueueCpRelationBroadcastFloatingIfNeeded(
Msg msg, { Msg msg, {
bool isRegionBroadcast = false, bool isRegionBroadcast = false,
@ -6490,10 +6544,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
'giftPhoto=${msg.gift?.giftPhoto}', 'giftPhoto=${msg.gift?.giftPhoto}',
); );
} else { } else {
final targetUserIds = final targetUserIds = _luckGiftTargetUserIdsFromMsg(msg);
(jsonDecode(msg.msg ?? "") as List)
.map((e) => e as String)
.toList();
_giftFxLog( _giftFxLog(
'recv LUCK_GIFT_ANIM_OTHER ' 'recv LUCK_GIFT_ANIM_OTHER '
'giftPhoto=${msg.gift?.giftPhoto} ' 'giftPhoto=${msg.gift?.giftPhoto} '
@ -6508,16 +6559,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
targetUserIds, targetUserIds,
), ),
); );
if (msg.user != null && msg.toUser != null && msg.gift != null) { if (!_shouldNotifyFloatingGiftListener(msg)) {
_giftFxLog(
'trigger floating gift listener from LUCK_GIFT_ANIM_OTHER '
'sendUserId=${msg.user?.id} '
'toUserId=${msg.toUser?.id} '
'quantity=${msg.number} '
'giftId=${msg.gift?.id}',
);
msgFloatingGiftListener?.call(msg);
} else {
_giftFxLog( _giftFxLog(
'skip floating gift listener from LUCK_GIFT_ANIM_OTHER ' 'skip floating gift listener from LUCK_GIFT_ANIM_OTHER '
'reason=incomplete_msg ' 'reason=incomplete_msg '

View File

@ -5,6 +5,10 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart';
void _seatFlightLog(String message) {
debugPrint('[GiftFx][seatFlight] $message');
}
typedef RoomGiftSeatTargetResolver = typedef RoomGiftSeatTargetResolver =
GlobalKey<State<StatefulWidget>>? Function(String targetUserId); GlobalKey<State<StatefulWidget>>? Function(String targetUserId);
@ -52,8 +56,19 @@ class RoomGiftSeatFlightController {
if (_state == null) { if (_state == null) {
_trimPendingRequestsOverflow(); _trimPendingRequestsOverflow();
_pendingRequests.add(normalizedRequest); _pendingRequests.add(normalizedRequest);
_seatFlightLog(
'buffer request no state '
'target=${normalizedRequest.targetUserId} '
'queueTag=${normalizedRequest.queueTag} '
'pending=${_pendingRequests.length}',
);
return; return;
} }
_seatFlightLog(
'enqueue via attached state '
'target=${normalizedRequest.targetUserId} '
'queueTag=${normalizedRequest.queueTag}',
);
_state!._enqueue(normalizedRequest); _state!._enqueue(normalizedRequest);
} }
@ -76,8 +91,20 @@ class RoomGiftSeatFlightController {
_trimPendingRequestsForTag(queueTag, maxTrackedRequests); _trimPendingRequestsForTag(queueTag, maxTrackedRequests);
_trimPendingRequestsOverflow(); _trimPendingRequestsOverflow();
_pendingRequests.add(normalizedRequest); _pendingRequests.add(normalizedRequest);
_seatFlightLog(
'buffer limited request no state '
'target=${normalizedRequest.targetUserId} '
'queueTag=$queueTag '
'pending=${_pendingRequests.length}',
);
return; return;
} }
_seatFlightLog(
'enqueue limited via attached state '
'target=${normalizedRequest.targetUserId} '
'queueTag=$queueTag '
'max=$maxTrackedRequests',
);
_state!._enqueueLimited( _state!._enqueueLimited(
normalizedRequest, normalizedRequest,
maxTrackedRequests: maxTrackedRequests, maxTrackedRequests: maxTrackedRequests,
@ -184,6 +211,7 @@ class RoomGiftSeatFlightController {
void _attach(_RoomGiftSeatFlightOverlayState state) { void _attach(_RoomGiftSeatFlightOverlayState state) {
_state = state; _state = state;
_seatFlightLog('attach overlay pending=${_pendingRequests.length}');
while (_pendingRequests.isNotEmpty) { while (_pendingRequests.isNotEmpty) {
state._enqueue(_pendingRequests.removeFirst()); state._enqueue(_pendingRequests.removeFirst());
} }
@ -191,6 +219,7 @@ class RoomGiftSeatFlightController {
void _detach(_RoomGiftSeatFlightOverlayState state) { void _detach(_RoomGiftSeatFlightOverlayState state) {
if (_state == state) { if (_state == state) {
_seatFlightLog('detach overlay');
_state = null; _state = null;
} }
} }
@ -262,6 +291,13 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_ensureCenterVisual(request); _ensureCenterVisual(request);
_trimQueuedRequestsOverflow(); _trimQueuedRequestsOverflow();
_queue.add(_QueuedRoomGiftSeatFlightRequest(request: request)); _queue.add(_QueuedRoomGiftSeatFlightRequest(request: request));
_seatFlightLog(
'state enqueue '
'target=${request.targetUserId} '
'queue=${_queue.length} '
'queueTag=${request.queueTag} '
'batchTag=${request.batchTag}',
);
_syncIdleCenterVisual(); _syncIdleCenterVisual();
_scheduleNextAnimation(); _scheduleNextAnimation();
} }
@ -411,6 +447,10 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
_queue.isEmpty || _queue.isEmpty ||
(expectedSessionToken != null && (expectedSessionToken != null &&
expectedSessionToken != _sessionToken)) { expectedSessionToken != _sessionToken)) {
_seatFlightLog(
'skip start mounted=$mounted playing=$_isPlaying queue=${_queue.length} '
'expected=$expectedSessionToken session=$_sessionToken',
);
return; return;
} }
@ -441,9 +481,16 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
); );
} }
final shouldRetryBatch = unresolvedBatch.any( _seatFlightLog(
(queuedRequest) => queuedRequest.retryCount < 6, 'start batch size=${queuedBatch.length} '
'active=${activeFlights.length} '
'unresolved=${unresolvedBatch.map((item) => item.request.targetUserId).join(",")} '
'queueRemaining=${_queue.length}',
); );
final shouldRetryBatch =
activeFlights.isEmpty &&
unresolvedBatch.any((queuedRequest) => queuedRequest.retryCount < 6);
if (shouldRetryBatch) { if (shouldRetryBatch) {
final retryBatch = final retryBatch =
queuedBatch queuedBatch
@ -466,6 +513,7 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
} }
if (activeFlights.isEmpty) { if (activeFlights.isEmpty) {
_seatFlightLog('drop batch no resolved targets');
_syncIdleCenterVisual(); _syncIdleCenterVisual();
_scheduleNextAnimation(); _scheduleNextAnimation();
return; return;
@ -594,6 +642,13 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
final targetKey = widget.resolveTargetKey(targetUserId); final targetKey = widget.resolveTargetKey(targetUserId);
final targetContext = targetKey?.currentContext; final targetContext = targetKey?.currentContext;
if (overlayContext == null || targetContext == null) { if (overlayContext == null || targetContext == null) {
_seatFlightLog(
'resolve target failed context '
'target=$targetUserId '
'hasOverlayContext=${overlayContext != null} '
'hasTargetKey=${targetKey != null} '
'hasTargetContext=${targetContext != null}',
);
return null; return null;
} }
@ -603,6 +658,12 @@ class _RoomGiftSeatFlightOverlayState extends State<RoomGiftSeatFlightOverlay>
targetBox is! RenderBox || targetBox is! RenderBox ||
!overlayBox.hasSize || !overlayBox.hasSize ||
!targetBox.hasSize) { !targetBox.hasSize) {
_seatFlightLog(
'resolve target failed render '
'target=$targetUserId '
'overlayBox=${overlayBox.runtimeType} '
'targetBox=${targetBox.runtimeType}',
);
return null; return null;
} }

View File

@ -14,6 +14,7 @@ class FloatingLuckyGiftGlobalScreenWidget extends StatefulWidget {
super.key, super.key,
required this.message, required this.message,
required this.onAnimationCompleted, required this.onAnimationCompleted,
this.debugDisableAutoSlide = false,
}); });
static const int floatingType = 7; static const int floatingType = 7;
@ -24,6 +25,7 @@ class FloatingLuckyGiftGlobalScreenWidget extends StatefulWidget {
final SCFloatingMessage message; final SCFloatingMessage message;
final VoidCallback onAnimationCompleted; final VoidCallback onAnimationCompleted;
final bool debugDisableAutoSlide;
@override @override
State<FloatingLuckyGiftGlobalScreenWidget> createState() => State<FloatingLuckyGiftGlobalScreenWidget> createState() =>
@ -73,7 +75,7 @@ class _FloatingLuckyGiftGlobalScreenWidgetState
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted && !widget.debugDisableAutoSlide) {
_controller.forward(); _controller.forward();
} }
}); });
@ -94,29 +96,19 @@ class _FloatingLuckyGiftGlobalScreenWidgetState
onTap: _handleTap, onTap: _handleTap,
onHorizontalDragEnd: _handleHorizontalDragEnd, onHorizontalDragEnd: _handleHorizontalDragEnd,
child: SlideTransition( child: SlideTransition(
position: _isSwipeAnimating ? _swipeAnimation : _offsetAnimation, position:
widget.debugDisableAutoSlide
? const AlwaysStoppedAnimation(Offset.zero)
: (_isSwipeAnimating ? _swipeAnimation : _offsetAnimation),
child: SizedBox( child: SizedBox(
width: FloatingLuckyGiftGlobalScreenWidget.bannerWidth.w, width: FloatingLuckyGiftGlobalScreenWidget.bannerWidth.w,
height: FloatingLuckyGiftGlobalScreenWidget.bannerHeight.w, height: FloatingLuckyGiftGlobalScreenWidget.bannerHeight.w,
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned(left: 70.w, top: 34.w, child: _buildAvatar()),
Positioned.fill(child: _buildBackground()), Positioned.fill(child: _buildBackground()),
Positioned(left: 30.w, top: 22.w, child: _buildAvatar()), Positioned.fill(child: _buildContentOverlay()),
Positioned(
left: 88.w,
top: 26.w,
width: 160.w,
height: 45.w,
child: _buildTextBlock(),
),
Positioned(
right: 23.w,
top: 24.w,
width: 61.w,
height: 45.w,
child: _buildMultiple(),
),
], ],
), ),
), ),
@ -150,6 +142,28 @@ class _FloatingLuckyGiftGlobalScreenWidgetState
); );
} }
Widget _buildContentOverlay() {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 110.w,
top: 24.w,
width: 120.w,
height: 46.w,
child: _buildTextBlock(),
),
Positioned(
left: 220.w,
top: 24.w,
width: 70.w,
height: 46.w,
child: _buildMultiple(),
),
],
);
}
Widget _buildAvatar() { Widget _buildAvatar() {
const fallbackAsset = "sc_images/general/sc_icon_avar_defalt.png"; const fallbackAsset = "sc_images/general/sc_icon_avar_defalt.png";
final avatarUrl = (widget.message.userAvatarUrl ?? "").trim(); final avatarUrl = (widget.message.userAvatarUrl ?? "").trim();
@ -157,37 +171,28 @@ class _FloatingLuckyGiftGlobalScreenWidgetState
avatarUrl.isEmpty avatarUrl.isEmpty
? Image.asset( ? Image.asset(
fallbackAsset, fallbackAsset,
width: 48.w, width: 26.w,
height: 48.w, height: 26.w,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Image( : Image(
image: buildCachedImageProvider( image: buildCachedImageProvider(
avatarUrl, avatarUrl,
logicalWidth: 48.w, logicalWidth: 38.w,
logicalHeight: 48.w, logicalHeight: 38.w,
), ),
width: 48.w, width: 38.w,
height: 48.w, height: 38.w,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: errorBuilder:
(_, __, ___) => Image.asset( (_, __, ___) => Image.asset(
fallbackAsset, fallbackAsset,
width: 48.w, width: 38.w,
height: 48.w, height: 38.w,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
); );
return Container( return ClipOval(child: avatar);
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFFFF4C2), width: 1.5.w),
),
clipBehavior: Clip.antiAlias,
child: avatar,
);
} }
Widget _buildTextBlock() { Widget _buildTextBlock() {
@ -201,20 +206,20 @@ class _FloatingLuckyGiftGlobalScreenWidgetState
title, title,
TextStyle( TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 19.sp, fontSize: 14.sp,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
height: 1, height: 1,
decoration: TextDecoration.none, decoration: TextDecoration.none,
shadows: _textShadows(), shadows: _textShadows(),
), ),
), ),
SizedBox(height: 7.w), SizedBox(height: 1.w),
_singleLineText( _singleLineText(
subtitle, subtitle,
TextStyle( TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 13.sp, fontSize: 10.5.sp,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w600,
height: 1, height: 1,
decoration: TextDecoration.none, decoration: TextDecoration.none,
shadows: _textShadows(), shadows: _textShadows(),
@ -234,7 +239,7 @@ class _FloatingLuckyGiftGlobalScreenWidgetState
textScaler: TextScaler.noScaling, textScaler: TextScaler.noScaling,
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 32.sp, fontSize: 23.sp,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
height: 1, height: 1,
decoration: TextDecoration.none, decoration: TextDecoration.none,

View File

@ -377,41 +377,42 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
} }
final imageReplacementsByName = final imageReplacementsByName =
_imageReplacementsByName ?? const <String, Uint8List>{}; _imageReplacementsByName ?? const <String, Uint8List>{};
final useNativeAssetView = final useNativeView = _shouldUseNativeRoomRocketPagView(
assetName != null && Platform.isIOS && widget.loop; isIOS: Platform.isIOS,
hasAsset: assetName != null,
hasBytes: bytes != null,
);
final pagView = final pagView =
assetName != null assetName != null
? PAGView.asset( ? PAGView.asset(
assetName, assetName,
key: _pagKey, key: _pagKey,
width: useNativeAssetView ? widget.width : null, width: useNativeView ? widget.width : null,
height: useNativeAssetView ? widget.height : null, height: useNativeView ? widget.height : null,
autoPlay: useNativeAssetView, autoPlay: useNativeView,
usePlatformView: useNativeAssetView, usePlatformView: useNativeView,
repeatCount: repeatCount, repeatCount: repeatCount,
onInit: useNativeAssetView ? null : _handleInit, onInit: useNativeView ? null : _handleInit,
onAnimationStart: onAnimationStart: useNativeView ? null : _handleAnimationStart,
useNativeAssetView ? null : _handleAnimationStart, onAnimationEnd: useNativeView ? null : _handleAnimationEnd,
onAnimationEnd: useNativeAssetView ? null : _handleAnimationEnd, onAnimationCancel: useNativeView ? null : _handleAnimationCancel,
onAnimationCancel: onAnimationRepeat: useNativeView ? null : _handleAnimationRepeat,
useNativeAssetView ? null : _handleAnimationCancel,
onAnimationRepeat:
useNativeAssetView ? null : _handleAnimationRepeat,
defaultBuilder: (_) => const SizedBox(width: 1, height: 1), defaultBuilder: (_) => const SizedBox(width: 1, height: 1),
imageReplacementsByName: imageReplacementsByName, imageReplacementsByName: imageReplacementsByName,
) )
: PAGView.bytes( : PAGView.bytes(
bytes, bytes,
key: _pagKey, key: _pagKey,
width: null, width: useNativeView ? widget.width : null,
height: null, height: useNativeView ? widget.height : null,
autoPlay: false, autoPlay: useNativeView,
usePlatformView: useNativeView,
repeatCount: repeatCount, repeatCount: repeatCount,
onInit: _handleInit, onInit: useNativeView ? null : _handleInit,
onAnimationStart: _handleAnimationStart, onAnimationStart: useNativeView ? null : _handleAnimationStart,
onAnimationEnd: _handleAnimationEnd, onAnimationEnd: useNativeView ? null : _handleAnimationEnd,
onAnimationCancel: _handleAnimationCancel, onAnimationCancel: useNativeView ? null : _handleAnimationCancel,
onAnimationRepeat: _handleAnimationRepeat, onAnimationRepeat: useNativeView ? null : _handleAnimationRepeat,
defaultBuilder: (_) => const SizedBox(width: 1, height: 1), defaultBuilder: (_) => const SizedBox(width: 1, height: 1),
imageReplacementsByName: imageReplacementsByName, imageReplacementsByName: imageReplacementsByName,
); );
@ -422,7 +423,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
widget.clipBehavior == Clip.none ? Clip.none : Clip.hardEdge, widget.clipBehavior == Clip.none ? Clip.none : Clip.hardEdge,
children: [ children: [
if (visibleUnderlay != null) visibleUnderlay, if (visibleUnderlay != null) visibleUnderlay,
if (useNativeAssetView) if (useNativeView)
Positioned.fill(child: pagView) Positioned.fill(child: pagView)
else else
_scaledPagView(pagView), _scaledPagView(pagView),
@ -475,7 +476,11 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
urlsByName: imageReplacementUrls, urlsByName: imageReplacementUrls,
), ),
); );
if (widget.loop) { if (_shouldUseNativeRoomRocketPagView(
isIOS: Platform.isIOS,
hasAsset: true,
hasBytes: false,
)) {
_startWatchdog?.cancel(); _startWatchdog?.cancel();
_initialized = true; _initialized = true;
} }
@ -530,13 +535,25 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> {
'head=${_pagByteHead(bytes)} ' 'head=${_pagByteHead(bytes)} '
'elapsedMs=${stopwatch.elapsedMilliseconds}', 'elapsedMs=${stopwatch.elapsedMilliseconds}',
); );
final useNativeView = _shouldUseNativeRoomRocketPagView(
isIOS: Platform.isIOS,
hasAsset: false,
hasBytes: true,
);
setState(() { setState(() {
_pagBytes = bytes; _pagBytes = bytes;
_imageReplacementsByName = imageReplacements; _imageReplacementsByName = imageReplacements;
_releasedFromMemory = false; _releasedFromMemory = false;
if (useNativeView) {
_initialized = true;
}
}); });
_scheduleMemoryRelease(path); _scheduleMemoryRelease(path);
_restartWatchdog(); if (useNativeView) {
_startWatchdog?.cancel();
} else {
_restartWatchdog();
}
} catch (error) { } catch (error) {
if (!_isCurrentResource(path, signature)) { if (!_isCurrentResource(path, signature)) {
return; return;
@ -907,6 +924,27 @@ String debugRoomRocketPagSourceKind(String resource) {
return _pagSourceKind(resource); return _pagSourceKind(resource);
} }
@visibleForTesting
bool debugShouldUseNativeRoomRocketPagView({
required bool isIOS,
required bool hasAsset,
required bool hasBytes,
}) {
return _shouldUseNativeRoomRocketPagView(
isIOS: isIOS,
hasAsset: hasAsset,
hasBytes: hasBytes,
);
}
bool _shouldUseNativeRoomRocketPagView({
required bool isIOS,
required bool hasAsset,
required bool hasBytes,
}) {
return isIOS && (hasAsset || hasBytes);
}
Future<Uint8List> _readPagBytes(String value) async { Future<Uint8List> _readPagBytes(String value) async {
final normalized = _normalizePagResource(value); final normalized = _normalizePagResource(value);
final uri = _pagNetworkUri(normalized); final uri = _pagNetworkUri(normalized);

View File

@ -1,14 +1,32 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:extended_image/extended_image.dart'; import 'package:extended_image/extended_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:yumi/app/constants/sc_global_config.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/shared/tools/sc_network_image_utils.dart';
import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart'; import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart';
const Duration _roomEmojiGifRestartInterval = Duration(milliseconds: 3500); const Duration _roomEmojiGifRestartInterval = Duration(milliseconds: 3500);
String _roomEmojiCacheKey(String resource, Object token) {
return 'room_emoji_${base64UrlEncode(utf8.encode('$resource|$token'))}';
}
int? _resolveRoomEmojiImageCacheDimension(double? logicalSize) {
if (logicalSize == null || !logicalSize.isFinite || logicalSize <= 0) {
return null;
}
final devicePixelRatio =
PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 2.0;
final maxDimension = SCGlobalConfig.isLowPerformanceDevice ? 1280 : 1920;
final pixelSize = (logicalSize * devicePixelRatio).round();
return pixelSize.clamp(1, maxDimension).toInt();
}
class RoomEmojiAssetImage extends StatefulWidget { class RoomEmojiAssetImage extends StatefulWidget {
const RoomEmojiAssetImage({ const RoomEmojiAssetImage({
super.key, super.key,
@ -71,7 +89,7 @@ class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
height: widget.height, height: widget.height,
loop: true, loop: true,
fit: widget.fit, fit: widget.fit,
fallback: _buildNetworkFallback(resource, 'svga fallback'), fallback: _buildNetworkFailure(resource, 'svga failed'),
), ),
); );
} }
@ -135,6 +153,9 @@ class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
headers: buildNetworkImageHeaders(normalizedUrl), headers: buildNetworkImageHeaders(normalizedUrl),
cache: true, cache: true,
clearMemoryCacheWhenDispose: false, clearMemoryCacheWhenDispose: false,
clearMemoryCacheIfFailed: true,
cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width),
cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height),
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
fit: widget.fit, fit: widget.fit,
@ -166,23 +187,19 @@ class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
'failed error=$error', 'failed error=$error',
normalizedUrl, normalizedUrl,
); );
return _buildNetworkFallback(normalizedUrl, 'image failed'); return _buildNetworkFailure(normalizedUrl, 'image failed');
} }
}, },
); );
} }
Widget _buildNetworkFallback(String resource, String reason) { Widget _buildNetworkFailure(String resource, String reason) {
_logNetworkOnce( _logNetworkOnce(
_loggedNetworkError, _loggedNetworkError,
reason, reason,
normalizeImageResourceUrl(resource), normalizeImageResourceUrl(resource),
); );
return SizedBox( return SizedBox(width: widget.width, height: widget.height);
width: widget.width,
height: widget.height,
child: const Center(child: SCRotatingDotsLoading()),
);
} }
void _logNetworkOnce(Set<String> bucket, String message, String url) { void _logNetworkOnce(Set<String> bucket, String message, String url) {
@ -291,8 +308,11 @@ class _RestartingGifImageState extends State<_RestartingGifImage> {
key: ValueKey('room_emoji_gif_placeholder_${normalizedUrl}_$token'), key: ValueKey('room_emoji_gif_placeholder_${normalizedUrl}_$token'),
headers: buildNetworkImageHeaders(normalizedUrl), headers: buildNetworkImageHeaders(normalizedUrl),
cache: true, cache: true,
cacheKey: '$normalizedUrl#room_emoji_gif_${token % 2}', cacheKey: _roomEmojiCacheKey(normalizedUrl, token % 2),
clearMemoryCacheWhenDispose: false, clearMemoryCacheWhenDispose: false,
clearMemoryCacheIfFailed: true,
cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width),
cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height),
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
fit: widget.fit, fit: widget.fit,
@ -317,8 +337,11 @@ class _RestartingGifImageState extends State<_RestartingGifImage> {
), ),
headers: buildNetworkImageHeaders(normalizedUrl), headers: buildNetworkImageHeaders(normalizedUrl),
cache: true, cache: true,
cacheKey: '$normalizedUrl#room_emoji_gif_${_restartToken % 2}', cacheKey: _roomEmojiCacheKey(normalizedUrl, _restartToken % 2),
clearMemoryCacheWhenDispose: true, clearMemoryCacheWhenDispose: true,
clearMemoryCacheIfFailed: true,
cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width),
cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height),
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
fit: widget.fit, fit: widget.fit,
@ -365,11 +388,7 @@ class _RestartingGifImageState extends State<_RestartingGifImage> {
case LoadState.failed: case LoadState.failed:
final error = state.lastException ?? 'unknown'; final error = state.lastException ?? 'unknown';
_debugGifOnce(_loggedError, 'failed error=$error', normalizedUrl); _debugGifOnce(_loggedError, 'failed error=$error', normalizedUrl);
return SizedBox( return SizedBox(width: widget.width, height: widget.height);
width: widget.width,
height: widget.height,
child: const Center(child: SCRotatingDotsLoading()),
);
} }
}, },
); );

View File

@ -1781,7 +1781,7 @@ packages:
source: hosted source: hosted
version: "4.4.2" version: "4.4.2"
webview_flutter_android: webview_flutter_android:
dependency: transitive dependency: "direct main"
description: description:
name: webview_flutter_android name: webview_flutter_android
sha256: "47a8da40d02befda5b151a26dba71f47df471cddd91dfdb7802d0a87c5442558" sha256: "47a8da40d02befda5b151a26dba71f47df471cddd91dfdb7802d0a87c5442558"

View File

@ -58,6 +58,7 @@ dependencies:
#权限请求 #权限请求
permission_handler: ^12.0.0+1 permission_handler: ^12.0.0+1
webview_flutter: 4.4.2 webview_flutter: 4.4.2
webview_flutter_android: 3.16.9
#跳转外链 #跳转外链
url_launcher: ^6.3.1 url_launcher: ^6.3.1
#键值对存储 #键值对存储

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yumi/modules/room_game/views/baishun_loading_view.dart';
void main() {
testWidgets('room game loading overlay can be placed inside stack', (
WidgetTester tester,
) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder:
(_, __) => const MaterialApp(
home: Scaffold(
body: Stack(
children: [
ColoredBox(color: Colors.black),
Positioned.fill(
child: IgnorePointer(child: BaishunLoadingView()),
),
],
),
),
),
),
);
expect(tester.takeException(), isNull);
expect(find.byType(BaishunLoadingView), findsOneWidget);
});
}

View File

@ -44,6 +44,33 @@ void main() {
expect(debugRoomRocketPagSourceKind('assets/rocket/rocket.pag'), 'asset'); expect(debugRoomRocketPagSourceKind('assets/rocket/rocket.pag'), 'asset');
}); });
test('uses native iOS pag view for loaded bytes and assets', () {
expect(
debugShouldUseNativeRoomRocketPagView(
isIOS: true,
hasAsset: false,
hasBytes: true,
),
isTrue,
);
expect(
debugShouldUseNativeRoomRocketPagView(
isIOS: true,
hasAsset: true,
hasBytes: false,
),
isTrue,
);
expect(
debugShouldUseNativeRoomRocketPagView(
isIOS: false,
hasAsset: true,
hasBytes: true,
),
isFalse,
);
});
test('keeps pag bytes in memory for five minutes by default', () { test('keeps pag bytes in memory for five minutes by default', () {
expect(debugRoomRocketPagMemoryTtl(), const Duration(minutes: 5)); expect(debugRoomRocketPagMemoryTtl(), const Duration(minutes: 5));
}); });

View File

@ -1055,3 +1055,4 @@
- [x] 2026-06-02 继续优化公屏 GIF 循环闪 loading网络 GIF 重播时不再销毁整个 widget加载下一轮期间复用上一帧画面避免循环间隙出现转圈。 - [x] 2026-06-02 继续优化公屏 GIF 循环闪 loading网络 GIF 重播时不再销毁整个 widget加载下一轮期间复用上一帧画面避免循环间隙出现转圈。
- [x] 2026-06-02 复核发现不销毁 key 会导致 GIF 解码器不重启并停在最后一帧;改为前景层用新 key 重播、背景层保留上一轮画面,兼顾循环播放和不显示 loading。 - [x] 2026-06-02 复核发现不销毁 key 会导致 GIF 解码器不重启并停在最后一帧;改为前景层用新 key 重播、背景层保留上一轮画面,兼顾循环播放和不显示 loading。
- [x] 2026-06-02 按接口文档恢复 `have` 权限判断:`/material/emoji/all` 返回的表情包仅 `have == true` 且非空时展示,`have=false/null` 计入 `filteredNoAccess` 日志。 - [x] 2026-06-02 按接口文档恢复 `have` 权限判断:`/material/emoji/all` 返回的表情包仅 `have == true` 且非空时展示,`have=false/null` 计入 `filteredNoAccess` 日志。
- [x] 2026-06-02 排查 Android 表情包一直转圈:初步判断不是接口解析,表情组已进入渲染后媒体加载失败态被转圈 fallback 掩盖;表情图片/GIF 按项目通用 `netImage` 补解码尺寸、失败清缓存,并把失败 fallback 改为静态占位便于区分 loading 与 failed。