自制飞屏动画处理

安卓待验证
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';
}
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}) {
checkedGift = gift;
if (gift != null &&
@ -1602,13 +1548,6 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
_showLocalCpInviteWaitingAfterGift(request, request.acceptUsers);
if (request.isLuckyGiftRequest) {
final giftBatchId = _createGiftBatchId(request);
_showLocalLuckyGiftFeedback(
request.acceptUsers,
gift: request.gift,
quantity: request.quantity,
giftBatchId: giftBatchId,
animationCount: request.clickCount,
);
await sendLuckGiftAnimOtherMsg(
request.acceptUsers,
gift: request.gift,
@ -2127,7 +2066,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
required String giftBatchId,
int animationCount = 1,
}) async {
final targetUserIds = _resolveAcceptUserIds(acceptUsers);
final targetUserIds = _resolveAcceptUserIdentityKeys(acceptUsers);
final firstTargetUser =
acceptUsers.isNotEmpty ? acceptUsers.first.user : null;
_giftFxLog(
@ -2156,7 +2095,7 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
number: quantity,
customAnimationCount: animationCount,
type: SCRoomMsgType.luckGiftAnimOther,
msg: jsonEncode(acceptUsers.map((u) => u.user?.id).toList()),
msg: jsonEncode(targetUserIds),
),
addLocal: true,
);
@ -2179,6 +2118,26 @@ class _GiftPageState extends State<GiftPage> with TickerProviderStateMixin {
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) {
final sortedAcceptUserIds = List<String>.from(request.acceptUserIds)
..sort();

View File

@ -175,6 +175,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
void _handleRoomRouteVisible() {
_releaseCoveredRouteGuard();
_setRoomRouteVisible(true);
_scheduleEnsureRoomVisualEffectsEnabled();
}
void _scheduleRoomRouteVisible() {
@ -249,13 +250,20 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
rtmProvider.msgFloatingGiftListener = _floatingGiftListener;
rtmProvider.msgLuckyGiftRewardTickerListener =
_luckyGiftRewardTickerListener;
debugPrint('[GiftFx][voice] bind room visual effect listeners');
}
void _ensureRoomVisualEffectsEnabled() {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
if (rtcProvider.currenRoom == null ||
!_roomRouteVisible ||
rtcProvider.roomVisualEffectsEnabled) {
if (rtcProvider.currenRoom == null || !_roomRouteVisible) {
return;
}
final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
final needsListenerRebind =
rtmProvider.msgFloatingGiftListener != _floatingGiftListener ||
rtmProvider.msgLuckyGiftRewardTickerListener !=
_luckyGiftRewardTickerListener;
if (rtcProvider.roomVisualEffectsEnabled && !needsListenerRebind) {
return;
}
_enableRoomVisualEffects();
@ -684,13 +692,36 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
_floatingGiftListener(Msg msg) {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
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;
}
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;
}
final targetUserIds = _resolveGiftTargetUserIds(msg);
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 =
targetUserIds.length <= 1 ||
targetGroupKey == null ||
@ -726,7 +757,10 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
listen: false,
).enqueueGiftAnimation(giftModel);
} 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();
@ -861,7 +895,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
bool _isLuckyGiftMessage(Msg msg) {
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) {
@ -956,26 +992,46 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
List<String> targetUserIds,
) {
if ((msg.number ?? 0) <= 0) {
debugPrint(
'[GiftFx][voice] skip lucky seat flight invalid quantity '
'giftId=${msg.gift?.id} quantity=${msg.number}',
);
return;
}
if (targetGroupKey == null || targetUserIds.isEmpty) {
debugPrint(
'[GiftFx][voice] skip lucky seat flight empty targets '
'giftId=${msg.gift?.id} targetGroupKey=$targetGroupKey',
);
return;
}
final sessionKey = _buildLuckyGiftComboSessionKey(msg, targetGroupKey);
final session = _luckyGiftComboSessions[sessionKey];
if (session == null) {
return;
}
final session = _luckyGiftComboSessions.putIfAbsent(
sessionKey,
() => _LuckyGiftComboSession(),
);
session.endTimer?.cancel();
session.clearQueueTimer?.cancel();
if (_shouldPlaySeatFlightGiftAnimation(msg) && giftPhoto.isNotEmpty) {
if (_shouldPlayLuckyGiftSeatFlightAnimation(msg) && giftPhoto.isNotEmpty) {
final flightBatchKey = _buildGiftFlightBatchKey(msg, targetUserIds);
if (targetUserIds.length > 1 &&
!_markGiftFlightBatchForPlayback(flightBatchKey)) {
debugPrint(
'[GiftFx][voice] skip lucky seat flight by flight batch dedupe '
'giftId=${msg.gift?.id} batchKey=$flightBatchKey',
);
_scheduleGiftAnimationSessionEnd(sessionKey);
return;
}
debugPrint(
'[GiftFx][voice] enqueue lucky seat flight '
'giftId=${msg.gift?.id} '
'giftPhoto=$giftPhoto '
'targetUserIds=${targetUserIds.join(",")} '
'animationCount=${_resolveTrackedAnimationCount(msg)} '
'batchKey=$flightBatchKey',
);
_enqueueTrackedSeatFlightAnimations(
sessionKey: sessionKey,
giftPhoto: giftPhoto,
@ -983,10 +1039,29 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
animationCount: _resolveTrackedAnimationCount(msg),
batchKey: flightBatchKey,
);
} else {
debugPrint(
'[GiftFx][voice] skip lucky seat flight unsupported gift photo '
'giftId=${msg.gift?.id} giftPhoto=$giftPhoto',
);
}
_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) {
return math.max(msg.customAnimationCount ?? 1, 1);
}
@ -1016,21 +1091,51 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
);
final normalizedTargetUserIds = _normalizeGiftTargetUserIds(targetUserIds);
if (normalizedTargetUserIds.isEmpty) {
debugPrint(
'[GiftFx][voice] skip seat flight enqueue empty normalized targets '
'sessionKey=$sessionKey',
);
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 cappedAnimationCount = math.min(
normalizedAnimationCount,
_maxTrackedGiftAnimations,
);
final maxTrackedRequests = math.max(
_maxTrackedGiftAnimations * normalizedTargetUserIds.length,
_maxTrackedGiftAnimations * flightTargetUserIds.length,
_maxTrackedGiftAnimations,
);
for (var index = 0; index < cappedAnimationCount; index += 1) {
final roundBatchTag =
normalizedTargetUserIds.length > 1 ? '$batchKey|round:$index' : null;
for (final targetUserId in normalizedTargetUserIds) {
flightTargetUserIds.length > 1 ? '$batchKey|round:$index' : null;
for (final targetUserId in flightTargetUserIds) {
_giftSeatFlightController.enqueueLimited(
RoomGiftSeatFlightRequest(
imagePath: giftPhoto,

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/modules/room_game/bridge/leader_js_bridge.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 _didReceiveBridgeMessage = false;
bool _didFinishPageLoad = false;
bool _shouldMountWebView = defaultTargetPlatform != TargetPlatform.android;
int _bridgeInjectCount = 0;
String? _errorMessage;
@ -105,6 +107,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
onPageFinished: (String url) async {
_didFinishPageLoad = true;
_log('page_finished url=${_clip(url, 240)}');
_showWebView(reason: 'page_finished');
await _injectBridge(reason: 'page_finished');
},
onWebResourceError: (WebResourceError error) {
@ -136,6 +139,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
void _prepareForPageLoad() {
_didReceiveBridgeMessage = false;
_didFinishPageLoad = false;
_shouldMountWebView = defaultTargetPlatform != TargetPlatform.android;
_bridgeInjectCount = 0;
_stopBridgeBootstrap(reason: 'prepare_page_load');
_bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), (
@ -155,6 +159,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
return;
}
_log('loading_fallback_fire');
_showWebView(reason: 'loading_fallback');
setState(() {
_isLoading = false;
});
@ -274,6 +279,7 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
if (!mounted) {
return;
}
_showWebView(reason: 'load_complete');
setState(() {
_isLoading = false;
_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 {
if (!mounted) {
return;
@ -385,7 +401,31 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
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}) {
final trimmed = value.trim();
@ -566,16 +606,14 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
color: const Color(0xFF081915),
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
bottom: bottomFrameHeight,
child: WebViewWidget(
controller: _controller,
gestureRecognizers: _webGestureRecognizers,
if (_shouldMountWebView)
Positioned(
left: 0,
right: 0,
top: 0,
bottom: bottomFrameHeight,
child: _buildWebView(),
),
),
if (bottomFrameHeight > 0)
Positioned(
left: 0,
@ -588,10 +626,12 @@ class _LeaderGamePageState extends State<LeaderGamePage> {
),
if (_errorMessage != null) _buildErrorState(),
if (_isLoading && _errorMessage == null)
const IgnorePointer(
ignoring: true,
child: BaishunLoadingView(
message: 'Waiting for Leader game...',
const Positioned.fill(
child: IgnorePointer(
ignoring: true,
child: BaishunLoadingView(
message: 'Waiting for Leader game...',
),
),
),
],

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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]) {
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_roles_type.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_red_packet_config_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() {
final currentUser = AccountStorage().getCurrentUser()?.userProfile;
return {currentUser?.id?.trim() ?? "", currentUser?.account?.trim() ?? ""}
..removeWhere((item) => item.isEmpty);
return {
currentUser?.id?.trim() ?? "",
currentUser?.account?.trim() ?? "",
currentUser?.getID().trim() ?? "",
}..removeWhere((item) => item.isEmpty);
}
Set<String> _userProfileIdentityKeys(SocialChatUserProfile? user) {
return {user?.id?.trim() ?? "", user?.account?.trim() ?? ""}
..removeWhere((item) => item.isEmpty);
return {
user?.id?.trim() ?? "",
user?.account?.trim() ?? "",
user?.getID().trim() ?? "",
}..removeWhere((item) => item.isEmpty);
}
bool _userProfileMatchesAnyIdentity(
@ -1564,8 +1572,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
}
void _syncSelfMicRuntimeState() {
final currentUserId = AccountStorage().getCurrentUser()?.userProfile?.id;
if ((currentUserId ?? "").isEmpty) {
final currentUserKeys = _currentUserIdentityKeys();
if (currentUserKeys.isEmpty) {
return;
}
final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? "";
@ -2417,6 +2425,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
bool keepAudioPublishing = false,
}) async {
if (!_roomRtcJoined || _roomRtcEngineAdapter == null) {
debugPrint(
'[RoomRtc] switchToBroadcaster skipped joined=$_roomRtcJoined '
'hasAdapter=${_roomRtcEngineAdapter != null}',
);
return false;
}
try {
@ -2425,6 +2437,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
RoomRtcAnchorConfig(muted: muted, publishMuted: localAudioMuted),
);
if (!switched) {
debugPrint('[RoomRtc] switchToBroadcaster failed adapter=false');
_roomRtcRoleIsBroadcaster = false;
_resyncHeartbeatFromRoomRtcState();
return false;
@ -2437,6 +2450,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_resyncHeartbeatFromRoomRtcState();
return true;
} catch (error) {
debugPrint('[RoomRtc] switchToBroadcaster error=$error');
_roomRtcRoleIsBroadcaster = false;
_resyncHeartbeatFromRoomRtcState();
return false;
@ -5198,14 +5212,63 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (normalizedUserId.isEmpty) {
return;
}
final lookupKeys = <String>{normalizedUserId};
final currentUserKeys = _currentUserIdentityKeys();
if (currentUserKeys.contains(normalizedUserId)) {
lookupKeys.addAll(currentUserKeys);
}
roomWheatMap.forEach((seatIndex, seat) {
if (seat.user?.id != normalizedUserId || seatIndex == exceptIndex) {
final seatUserKeys = _userProfileIdentityKeys(seat.user);
if (!seatUserKeys.any(lookupKeys.contains) || seatIndex == exceptIndex) {
return;
}
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) {
final normalizedUserId = (userId ?? '').trim();
if (normalizedUserId.isEmpty) {
@ -5269,66 +5332,29 @@ class RealTimeCommunicationManager extends ChangeNotifier {
final targetIndex = micGoUpRes.micIndex ?? index;
final selfMicMuted = micGoUpRes.micMute ?? false;
if (myUser != null) {
_applySelfMicGoUpState(
myUser: myUser,
targetIndex: targetIndex,
micGoUpRes: micGoUpRes,
selfMicMuted: selfMicMuted,
);
notifyListeners();
}
final switched = await _switchRoomRtcToBroadcaster(
muted: selfMicMuted,
keepAudioPublishing: selfMicMuted && _currentUserPublishingRoomMusic,
);
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);
_handleSelfMicGoUpFailure(
'Failed to put on the microphone, TRTC switch failed',
_resetSelfMicGoUpFailureState();
SCTts.show(
'Microphone audio failed to start, please try switching microphone again',
);
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 (isFz()) {
@ -5477,6 +5503,13 @@ class RealTimeCommunicationManager extends ChangeNotifier {
///
num userOnMaiInIndex(String userId) {
return _findUserOnMaiInIndex(userId, useDisplaySeatForCurrentUser: true);
}
num _findUserOnMaiInIndex(
String userId, {
required bool useDisplaySeatForCurrentUser,
}) {
final normalizedUserId = userId.trim();
if (normalizedUserId.isEmpty) {
return -1;
@ -5484,12 +5517,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
num index = -1;
final currentUserKeys = _currentUserIdentityKeys();
final isCurrentUserQuery = currentUserKeys.contains(normalizedUserId);
final lookupKeys =
isCurrentUserQuery ? currentUserKeys : <String>{normalizedUserId}
..removeWhere((item) => item.isEmpty);
final lookupKeys = <String>{normalizedUserId};
if (isCurrentUserQuery) {
lookupKeys.addAll(currentUserKeys);
}
lookupKeys.removeWhere((item) => item.isEmpty);
roomWheatMap.forEach((k, value) {
final visibleSeat = isCurrentUserQuery ? micAtIndexForDisplay(k) : value;
if (_userProfileMatchesAnyIdentity(visibleSeat?.user, lookupKeys)) {
final candidateSeat =
useDisplaySeatForCurrentUser && isCurrentUserQuery
? micAtIndexForDisplay(k)
: value;
if (_userProfileMatchesAnyIdentity(candidateSeat?.user, lookupKeys)) {
index = k;
}
});
@ -5780,7 +5818,10 @@ class RealTimeCommunicationManager extends ChangeNotifier {
GlobalKey<State<StatefulWidget>>? getSeatGlobalKeyByIndex(String userId) {
///
num index = userOnMaiInIndex(userId);
num index = _findUserOnMaiInIndex(
userId,
useDisplaySeatForCurrentUser: false,
);
if (index > -1) {
return seatGlobalKeyMap[index];
}

View File

@ -276,6 +276,33 @@ class RealTimeMessagingManager extends ChangeNotifier {
].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) {
for (final value in values) {
final text = value?.trim();
@ -2977,9 +3004,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
if (mergedGiftMsg != null) {
msgAllListener?.call(mergedGiftMsg);
msgGiftListener?.call(mergedGiftMsg);
if (msg.type == SCRoomMsgType.gift) {
msgFloatingGiftListener?.call(msg);
}
_notifyFloatingGiftListenerFromAddMsg(msg);
notifyListeners();
return;
}
@ -3071,12 +3096,41 @@ class RealTimeMessagingManager extends ChangeNotifier {
roomGiftMsgList.removeAt(roomGiftMsgList.length - 1);
}
msgGiftListener?.call(msg);
if (msg.type == SCRoomMsgType.gift) {
msgFloatingGiftListener?.call(msg);
}
_notifyFloatingGiftListenerFromAddMsg(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(
Msg msg, {
bool isRegionBroadcast = false,
@ -6490,10 +6544,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
'giftPhoto=${msg.gift?.giftPhoto}',
);
} else {
final targetUserIds =
(jsonDecode(msg.msg ?? "") as List)
.map((e) => e as String)
.toList();
final targetUserIds = _luckGiftTargetUserIdsFromMsg(msg);
_giftFxLog(
'recv LUCK_GIFT_ANIM_OTHER '
'giftPhoto=${msg.gift?.giftPhoto} '
@ -6508,16 +6559,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
targetUserIds,
),
);
if (msg.user != null && msg.toUser != null && msg.gift != null) {
_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 {
if (!_shouldNotifyFloatingGiftListener(msg)) {
_giftFxLog(
'skip floating gift listener from LUCK_GIFT_ANIM_OTHER '
'reason=incomplete_msg '

View File

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

View File

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

View File

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

View File

@ -1,14 +1,32 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/foundation.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/ui_kit/components/sc_rotating_dots_loading.dart';
import 'package:yumi/ui_kit/widgets/svga/sc_network_svga_widget.dart';
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 {
const RoomEmojiAssetImage({
super.key,
@ -71,7 +89,7 @@ class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
height: widget.height,
loop: true,
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),
cache: true,
clearMemoryCacheWhenDispose: false,
clearMemoryCacheIfFailed: true,
cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width),
cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height),
width: widget.width,
height: widget.height,
fit: widget.fit,
@ -166,23 +187,19 @@ class _RoomEmojiAssetImageState extends State<RoomEmojiAssetImage>
'failed error=$error',
normalizedUrl,
);
return _buildNetworkFallback(normalizedUrl, 'image failed');
return _buildNetworkFailure(normalizedUrl, 'image failed');
}
},
);
}
Widget _buildNetworkFallback(String resource, String reason) {
Widget _buildNetworkFailure(String resource, String reason) {
_logNetworkOnce(
_loggedNetworkError,
reason,
normalizeImageResourceUrl(resource),
);
return SizedBox(
width: widget.width,
height: widget.height,
child: const Center(child: SCRotatingDotsLoading()),
);
return SizedBox(width: widget.width, height: widget.height);
}
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'),
headers: buildNetworkImageHeaders(normalizedUrl),
cache: true,
cacheKey: '$normalizedUrl#room_emoji_gif_${token % 2}',
cacheKey: _roomEmojiCacheKey(normalizedUrl, token % 2),
clearMemoryCacheWhenDispose: false,
clearMemoryCacheIfFailed: true,
cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width),
cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height),
width: widget.width,
height: widget.height,
fit: widget.fit,
@ -317,8 +337,11 @@ class _RestartingGifImageState extends State<_RestartingGifImage> {
),
headers: buildNetworkImageHeaders(normalizedUrl),
cache: true,
cacheKey: '$normalizedUrl#room_emoji_gif_${_restartToken % 2}',
cacheKey: _roomEmojiCacheKey(normalizedUrl, _restartToken % 2),
clearMemoryCacheWhenDispose: true,
clearMemoryCacheIfFailed: true,
cacheWidth: _resolveRoomEmojiImageCacheDimension(widget.width),
cacheHeight: _resolveRoomEmojiImageCacheDimension(widget.height),
width: widget.width,
height: widget.height,
fit: widget.fit,
@ -365,11 +388,7 @@ class _RestartingGifImageState extends State<_RestartingGifImage> {
case LoadState.failed:
final error = state.lastException ?? 'unknown';
_debugGifOnce(_loggedError, 'failed error=$error', normalizedUrl);
return SizedBox(
width: widget.width,
height: widget.height,
child: const Center(child: SCRotatingDotsLoading()),
);
return SizedBox(width: widget.width, height: widget.height);
}
},
);

View File

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

View File

@ -56,10 +56,11 @@ dependencies:
#路由
fluro: 2.0.5
#权限请求
permission_handler: ^12.0.0+1
webview_flutter: 4.4.2
#跳转外链
url_launcher: ^6.3.1
permission_handler: ^12.0.0+1
webview_flutter: 4.4.2
webview_flutter_android: 3.16.9
#跳转外链
url_launcher: ^6.3.1
#键值对存储
# mmkv: ^2.2.2
shared_preferences: ^2.5.3

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');
});
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', () {
expect(debugRoomRocketPagMemoryTtl(), const Duration(minutes: 5));
});

View File

@ -1055,3 +1055,4 @@
- [x] 2026-06-02 继续优化公屏 GIF 循环闪 loading网络 GIF 重播时不再销毁整个 widget加载下一轮期间复用上一帧画面避免循环间隙出现转圈。
- [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 排查 Android 表情包一直转圈:初步判断不是接口解析,表情组已进入渲染后媒体加载失败态被转圈 fallback 掩盖;表情图片/GIF 按项目通用 `netImage` 补解码尺寸、失败清缓存,并把失败 fallback 改为静态占位便于区分 loading 与 failed。