import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; 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: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'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; import 'package:yumi/modules/room_game/views/baishun_loading_view.dart'; import 'package:yumi/modules/wallet/wallet_route.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; class LeaderGamePage extends StatefulWidget { const LeaderGamePage({ super.key, required this.roomId, required this.game, required this.launchModel, }); final String roomId; final RoomGameListItemModel game; final BaishunLaunchModel launchModel; @override State createState() => _LeaderGamePageState(); } class _LeaderGamePageState extends State { static const String _logPrefix = '[LeaderGame]'; static const double _designWidth = 750; static const double _hdDesignHeight = 1044; final RoomGameRepository _repository = RoomGameRepository(); final Set> _webGestureRecognizers = >{ Factory(() => EagerGestureRecognizer()), }; late final WebViewController _controller; Timer? _bridgeBootstrapTimer; Timer? _loadingFallbackTimer; bool _isLoading = true; bool _isClosing = false; bool _didReceiveBridgeMessage = false; bool _didFinishPageLoad = false; int _bridgeInjectCount = 0; String? _errorMessage; @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.black) ..addJavaScriptChannel( LeaderJsBridge.channelName, onMessageReceived: _handleBridgeMessage, ) ..addJavaScriptChannel( LeaderBridgeActions.getConfig, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( LeaderBridgeActions.getConfig, message, ), ) ..addJavaScriptChannel( LeaderBridgeActions.pay, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( LeaderBridgeActions.pay, message, ), ) ..addJavaScriptChannel( LeaderBridgeActions.closeGame, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( LeaderBridgeActions.closeGame, message, ), ) ..addJavaScriptChannel( LeaderBridgeActions.loadComplete, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( LeaderBridgeActions.loadComplete, message, ), ) ..setNavigationDelegate( NavigationDelegate( onPageStarted: (String url) { _log('page_started url=${_clip(url, 240)}'); _prepareForPageLoad(); }, onPageFinished: (String url) async { _didFinishPageLoad = true; _log('page_finished url=${_clip(url, 240)}'); await _injectBridge(reason: 'page_finished'); }, onWebResourceError: (WebResourceError error) { _log( 'web_resource_error code=${error.errorCode} ' 'type=${error.errorType} desc=${_clip(error.description, 300)}', ); _stopBridgeBootstrap(reason: 'web_resource_error'); if (!mounted) { return; } setState(() { _errorMessage = error.description; _isLoading = false; }); }, ), ); _log('init launch=${_stringifyForLog(_buildLaunchSummary())}'); unawaited(_loadGameEntry()); } @override void dispose() { _stopBridgeBootstrap(reason: 'dispose'); super.dispose(); } void _prepareForPageLoad() { _didReceiveBridgeMessage = false; _didFinishPageLoad = false; _bridgeInjectCount = 0; _stopBridgeBootstrap(reason: 'prepare_page_load'); _bridgeBootstrapTimer = Timer.periodic(const Duration(milliseconds: 250), ( Timer timer, ) { if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) { timer.cancel(); return; } unawaited(_injectBridge(reason: 'bootstrap')); if (_didFinishPageLoad && timer.tick >= 12) { timer.cancel(); } }); _loadingFallbackTimer = Timer(const Duration(seconds: 6), () { if (!mounted || _didReceiveBridgeMessage || _errorMessage != null) { return; } _log('loading_fallback_fire'); setState(() { _isLoading = false; }); }); if (!mounted) { return; } setState(() { _isLoading = true; _errorMessage = null; }); } void _stopBridgeBootstrap({String reason = 'manual'}) { if (_bridgeBootstrapTimer != null || _loadingFallbackTimer != null) { _log('stop_bootstrap reason=$reason'); } _bridgeBootstrapTimer?.cancel(); _bridgeBootstrapTimer = null; _loadingFallbackTimer?.cancel(); _loadingFallbackTimer = null; } Future _loadGameEntry() async { try { _prepareForPageLoad(); final entryUrl = widget.launchModel.entry.entryUrl.trim(); _log( 'load_entry launchMode=${widget.launchModel.entry.launchMode} ' 'entryUrl=${_clip(entryUrl, 240)}', ); if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) { await _controller.loadHtmlString(_buildMockHtml()); return; } final uri = Uri.tryParse(entryUrl); if (uri == null) { throw Exception('Invalid game entry url: $entryUrl'); } await _controller.loadRequest(uri); } catch (error) { _log('load_entry_error error=${_clip(error.toString(), 400)}'); if (!mounted) { return; } setState(() { _errorMessage = error.toString(); _isLoading = false; }); } } Future _injectBridge({String reason = 'manual'}) async { _bridgeInjectCount += 1; if (reason != 'bootstrap' || _bridgeInjectCount <= 3 || _bridgeInjectCount % 5 == 0) { _log('inject_bridge reason=$reason count=$_bridgeInjectCount'); } try { await _controller.runJavaScript( LeaderJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()), ); } catch (error) { _log( 'inject_bridge_error reason=$reason error=${_clip(error.toString(), 300)}', ); } } Future _handleBridgeMessage(JavaScriptMessage message) async { _log('channel_message raw=${_clip(message.message, 600)}'); final bridgeMessage = LeaderBridgeMessage.parse(message.message); await _dispatchBridgeMessage(bridgeMessage); } Future _handleNamedChannelMessage( String action, JavaScriptMessage message, ) async { final bridgeMessage = LeaderBridgeMessage.fromAction( action, message.message, ); _log('named_channel action=$action raw=${_clip(message.message, 600)}'); await _dispatchBridgeMessage(bridgeMessage); } Future _dispatchBridgeMessage(LeaderBridgeMessage bridgeMessage) async { if (bridgeMessage.action == LeaderBridgeActions.debugLog) { final tag = bridgeMessage.payload['tag']?.toString().trim(); final message = bridgeMessage.payload['message']?.toString() ?? ''; _log('h5_debug tag=${tag ?? 'unknown'} message=${_clip(message, 800)}'); return; } _didReceiveBridgeMessage = true; _stopBridgeBootstrap(reason: 'bridge_message_${bridgeMessage.action}'); _log( 'bridge_action action=${bridgeMessage.action} ' 'payload=${_clip(bridgeMessage.payload.toString(), 800)}', ); switch (bridgeMessage.action) { case LeaderBridgeActions.getConfig: await _injectBridge(reason: 'get_config'); break; case LeaderBridgeActions.pay: await SCNavigatorUtils.push(context, WalletRoute.recharge); await _notifyUpdateCoin(); break; case LeaderBridgeActions.closeGame: await _closeAndExit(reason: 'h5_closeGame'); break; case LeaderBridgeActions.loadComplete: if (!mounted) { return; } setState(() { _isLoading = false; _errorMessage = null; }); break; default: _log('bridge_action_unhandled action=${bridgeMessage.action}'); break; } } Future _notifyUpdateCoin() async { _log('notify_update_coin'); try { await _controller.runJavaScript(LeaderJsBridge.buildUpdateCoinScript()); } catch (error) { _log('notify_update_coin_error error=${_clip(error.toString(), 300)}'); } } Future _reload() async { if (!mounted) { return; } setState(() { _errorMessage = null; _isLoading = true; }); await _loadGameEntry(); } Future _closeAndExit({String reason = 'user_exit'}) async { if (_isClosing) { return; } _isClosing = true; try { final sessionId = widget.launchModel.gameSessionId.trim(); if (sessionId.isNotEmpty && !sessionId.startsWith('mock')) { await _repository.closeGame( provider: widget.game.provider, roomId: widget.roomId, gameSessionId: sessionId, reason: reason, params: const {}, ); } } catch (error) { _log('close_error error=${_clip(error.toString(), 300)}'); } if (mounted) { Navigator.of(context).pop(); } } String _resolveScreenMode() { final launchParams = widget.game.launchParams; final candidates = [ launchParams['screenMode']?.toString() ?? '', launchParams['screen_mode']?.toString() ?? '', launchParams['displayMode']?.toString() ?? '', launchParams['gameScreenMode']?.toString() ?? '', ]; for (final candidate in candidates) { final trimmed = candidate.trim(); if (trimmed.isNotEmpty) { return trimmed.toLowerCase(); } } if (widget.game.fullScreen) { return 'full'; } return 'half'; } int _resolveSafeHeight() { if (widget.launchModel.entry.safeHeight > 0) { return widget.launchModel.entry.safeHeight; } if (widget.game.safeHeight > 0) { return widget.game.safeHeight; } return 0; } double _calculateBodyHeight(BuildContext context) { final screenSize = MediaQuery.sizeOf(context); final safePadding = MediaQuery.paddingOf(context); final screenMode = _resolveScreenMode(); final safeHeight = _resolveSafeHeight(); final fallback = screenSize.width; final maxHeight = screenSize.height - safePadding.top - 24.w; if (maxHeight <= 0) { return fallback; } if (screenMode == 'full') { return maxHeight; } if (safeHeight > 0 && screenSize.width > 0) { final ratio = _designWidth / safeHeight; final bodyHeight = screenSize.width / ratio; return bodyHeight.clamp(0.0, maxHeight).toDouble(); } if (screenMode == 'hd') { final bodyHeight = screenSize.width * _hdDesignHeight / _designWidth; return bodyHeight.clamp(0.0, maxHeight).toDouble(); } return fallback.clamp(0.0, maxHeight).toDouble(); } void _log(String message) { debugPrint('$_logPrefix $message'); } String _maskValue(String value, {int keepStart = 6, int keepEnd = 4}) { final trimmed = value.trim(); if (trimmed.isEmpty) { return ''; } if (trimmed.length <= keepStart + keepEnd) { return trimmed; } return '${trimmed.substring(0, keepStart)}***${trimmed.substring(trimmed.length - keepEnd)}'; } Object? _sanitizeForLog(Object? value, {String key = ''}) { final lowerKey = key.toLowerCase(); final shouldMask = lowerKey.contains('code') || lowerKey.contains('token') || lowerKey.contains('authorization'); if (value is Map) { final result = {}; for (final MapEntry entry in value.entries) { result[entry.key.toString()] = _sanitizeForLog( entry.value, key: entry.key.toString(), ); } return result; } if (value is List) { return value.map((Object? item) => _sanitizeForLog(item)).toList(); } if (value is String) { final normalized = shouldMask ? _maskValue(value) : value; return _clip(normalized, 600); } return value; } String _clip(String value, [int limit = 600]) { final trimmed = value.trim(); if (trimmed.length <= limit) { return trimmed; } return '${trimmed.substring(0, limit)}...'; } String _stringifyForLog(Object? value) { if (value == null) { return ''; } try { return _clip(jsonEncode(value), 800); } catch (_) { return _clip(value.toString(), 800); } } Map _buildLaunchPayload() { return { 'provider': widget.launchModel.provider, 'gameId': widget.launchModel.gameId, 'providerGameId': widget.launchModel.providerGameId, 'gameSessionId': widget.launchModel.gameSessionId, 'entry': widget.launchModel.entry.toJson(), 'launchConfig': widget.launchModel.launchConfig.toJson(), 'roomState': widget.launchModel.roomState.toJson(), 'game': { 'gameId': widget.game.gameId, 'provider': widget.game.provider, 'gameType': widget.game.gameType, 'name': widget.game.name, 'launchMode': widget.game.launchMode, }, }; } Map _buildLaunchSummary() { final payload = _buildLaunchPayload(); return { 'roomId': widget.roomId, 'provider': widget.game.provider, 'gameId': widget.game.gameId, 'providerGameId': widget.launchModel.providerGameId, 'gameSessionId': widget.launchModel.gameSessionId, 'entry': _sanitizeForLog(payload['entry']), 'launchConfig': _sanitizeForLog(payload['launchConfig']), 'roomState': _sanitizeForLog(payload['roomState']), }; } String _buildMockHtml() { return '''

Leader Mock

Use these buttons to verify bridge wiring.

'''; } @override Widget build(BuildContext context) { final bodyHeight = _calculateBodyHeight(context); final headerHeight = 44.w; return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (didPop) { return; } await _closeAndExit(); }, child: Material( color: Colors.transparent, child: Align( alignment: Alignment.bottomCenter, child: ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(24.w), topRight: Radius.circular(24.w), ), child: Container( width: ScreenUtil().screenWidth, height: bodyHeight + headerHeight, color: const Color(0xFF081915), child: Stack( children: [ Positioned( left: 0, right: 0, top: 0, height: headerHeight, child: _buildHeader(), ), Positioned( left: 0, right: 0, top: headerHeight, bottom: 0, child: WebViewWidget( controller: _controller, gestureRecognizers: _webGestureRecognizers, ), ), if (_errorMessage != null) _buildErrorState(), if (_isLoading && _errorMessage == null) const IgnorePointer( ignoring: true, child: BaishunLoadingView( message: 'Waiting for Leader game...', ), ), ], ), ), ), ), ), ); } Widget _buildHeader() { return Container( color: const Color(0xFF102D28), padding: EdgeInsets.symmetric(horizontal: 12.w), child: Row( children: [ Expanded( child: Text( widget.game.name.isEmpty ? 'Leader Game' : widget.game.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontSize: 14.sp, fontWeight: FontWeight.w600, ), ), ), IconButton( onPressed: () { unawaited(_closeAndExit()); }, icon: Icon(Icons.close, color: Colors.white, size: 20.w), splashRadius: 18.w, ), ], ), ); } Widget _buildErrorState() { return Positioned.fill( child: Container( color: Colors.black.withValues(alpha: 0.88), padding: EdgeInsets.symmetric(horizontal: 28.w), alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, color: Colors.white70, size: 34.w), SizedBox(height: 12.w), Text( 'Leader game failed to load', style: TextStyle( color: Colors.white, fontSize: 16.sp, fontWeight: FontWeight.w700, ), ), SizedBox(height: 8.w), Text( _errorMessage ?? '', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12.sp), ), SizedBox(height: 16.w), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton(onPressed: _reload, child: const Text('Retry')), SizedBox(width: 10.w), TextButton( onPressed: () { SCTts.show('Please verify the Leader entry url'); }, child: const Text('Tips'), ), ], ), ], ), ), ); } }