import 'dart:async'; import 'dart:convert'; 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: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'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; import 'package:yumi/modules/room_game/views/baishun_debug_panel.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 BaishunGamePage extends StatefulWidget { const BaishunGamePage({ super.key, required this.roomId, required this.game, required this.launchModel, }); final String roomId; final RoomGameListItemModel game; final BaishunLaunchModel launchModel; @override State createState() => _BaishunGamePageState(); } class _BaishunGamePageState extends State { final RoomGameRepository _repository = RoomGameRepository(); late final WebViewController _controller; Timer? _bridgeBootstrapTimer; Timer? _loadingFallbackTimer; final List _debugLogs = []; bool _isLoading = true; bool _isClosing = false; bool _didReceiveBridgeMessage = false; bool _didFinishPageLoad = false; bool _hasDeliveredLaunchConfig = false; int _bridgeInjectCount = 0; int _configSendCount = 0; String? _errorMessage; String _lastBridgeSource = ''; String _lastBridgeAction = ''; String _lastBridgePayload = ''; String _lastJsCallback = ''; String _lastConfigSummary = ''; @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.black) ..addJavaScriptChannel( BaishunJsBridge.channelName, onMessageReceived: _handleBridgeMessage, ) ..addJavaScriptChannel( BaishunBridgeActions.getConfig, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( BaishunBridgeActions.getConfig, message, ), ) ..addJavaScriptChannel( BaishunBridgeActions.destroy, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( BaishunBridgeActions.destroy, message, ), ) ..addJavaScriptChannel( BaishunBridgeActions.gameRecharge, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( BaishunBridgeActions.gameRecharge, message, ), ) ..addJavaScriptChannel( BaishunBridgeActions.gameLoaded, onMessageReceived: (JavaScriptMessage message) => _handleNamedChannelMessage( BaishunBridgeActions.gameLoaded, message, ), ) ..setNavigationDelegate( NavigationDelegate( onPageStarted: (String _) { _prepareForPageLoad(); _appendDebugLog('page', 'page started'); }, onPageFinished: (String _) async { _didFinishPageLoad = true; _appendDebugLog('page', 'page finished'); await _injectBridge(reason: 'page_finished'); }, onWebResourceError: (WebResourceError error) { _stopBridgeBootstrap(); _appendDebugLog( 'error', 'web resource error: ${error.description}', ); if (!mounted) { return; } setState(() { _errorMessage = error.description; _isLoading = false; }); }, ), ); unawaited(_loadGameEntry()); } @override void dispose() { _stopBridgeBootstrap(); super.dispose(); } void _prepareForPageLoad() { _didReceiveBridgeMessage = false; _didFinishPageLoad = false; _hasDeliveredLaunchConfig = false; _bridgeInjectCount = 0; _configSendCount = 0; _stopBridgeBootstrap(); _lastBridgeSource = ''; _lastBridgeAction = ''; _lastBridgePayload = ''; _lastJsCallback = ''; _lastConfigSummary = ''; _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; } _appendDebugLog('loading', 'timeout fallback, hide loading'); setState(() { _isLoading = false; }); }); if (!mounted) { return; } setState(() { _isLoading = true; _errorMessage = null; }); } void _stopBridgeBootstrap() { _bridgeBootstrapTimer?.cancel(); _bridgeBootstrapTimer = null; _loadingFallbackTimer?.cancel(); _loadingFallbackTimer = null; } Future _loadGameEntry() async { try { _prepareForPageLoad(); final entryUrl = widget.launchModel.entry.entryUrl.trim(); _appendDebugLog( 'launch', 'load entry launchMode=${widget.launchModel.entry.launchMode} url=$entryUrl', ); if (entryUrl.isEmpty || entryUrl.startsWith('mock://')) { final html = await rootBundle.loadString( 'assets/debug/baishun_mock/index.html', ); await _controller.loadHtmlString( html, baseUrl: 'https://baishun.mock.local/', ); return; } final uri = Uri.tryParse(entryUrl); if (uri == null) { throw Exception('Invalid game entry url: $entryUrl'); } await _controller.loadRequest(uri); } catch (error) { _appendDebugLog('error', 'load entry failed: $error'); if (!mounted) { return; } setState(() { _errorMessage = error.toString(); _isLoading = false; }); } } Future _injectBridge({String reason = 'manual'}) async { _bridgeInjectCount += 1; if (reason != 'bootstrap') { _appendDebugLog('inject', 'reason=$reason count=$_bridgeInjectCount'); } try { await _controller.runJavaScript(BaishunJsBridge.bootstrapScript()); } catch (_) {} } Future _sendConfigToGame({ required String reason, String? jsCallbackPath, bool force = false, }) async { if (_hasDeliveredLaunchConfig && !force) { _appendDebugLog( 'config', 'skip duplicate config send because BAISHUN code is one-time', ); return; } _configSendCount += 1; _hasDeliveredLaunchConfig = true; final rawConfig = widget.launchModel.bridgeConfig; final config = _buildEffectiveBridgeConfig(rawConfig); final code = config.code.trim(); final maskedCode = code.length <= 8 ? code : '${code.substring(0, 4)}...${code.substring(code.length - 4)}'; _lastConfigSummary = 'appChannel=${config.appChannel}, appId=${config.appId}, userId=${config.userId}, roomId=${config.roomId}, gameMode=${config.gameMode}, language=${config.language}(raw=${rawConfig.language}), gsp=${config.gsp}, code=$maskedCode(len=${code.length})'; if (config.language != rawConfig.language.trim()) { _appendDebugLog( 'config', 'normalize language ${rawConfig.language} -> ${config.language} by BAISHUN table', ); } _appendDebugLog( 'config', 'reason=$reason count=$_configSendCount callback=${jsCallbackPath?.trim().isNotEmpty == true ? jsCallbackPath!.trim() : '--'} $_lastConfigSummary', ); if (mounted) { setState(() {}); } try { await _controller.runJavaScript( BaishunJsBridge.buildConfigScript( config, jsCallbackPath: jsCallbackPath, ), ); } catch (_) {} } Future _handleBridgeMessage(JavaScriptMessage message) async { final bridgeMessage = BaishunBridgeMessage.parse(message.message); await _dispatchBridgeMessage( bridgeMessage, source: BaishunJsBridge.channelName, rawMessage: message.message, ); } Future _handleNamedChannelMessage( String action, JavaScriptMessage message, ) async { final bridgeMessage = BaishunBridgeMessage.fromAction( action, message.message, ); await _dispatchBridgeMessage( bridgeMessage, source: 'channel:$action', rawMessage: message.message, ); } Future _dispatchBridgeMessage( BaishunBridgeMessage bridgeMessage, { required String source, required String rawMessage, }) async { if (bridgeMessage.action == BaishunBridgeActions.debugLog) { final tag = bridgeMessage.payload['tag']?.toString().trim(); final message = bridgeMessage.payload['message']?.toString().trim(); if ((tag ?? '').isNotEmpty || (message ?? '').isNotEmpty) { _appendDebugLog( tag == null || tag.isEmpty ? 'h5' : 'h5:$tag', message == null || message.isEmpty ? rawMessage.trim() : message, ); } return; } final callbackPath = _extractCallbackPath(bridgeMessage.payload); if (bridgeMessage.action.isNotEmpty) { _didReceiveBridgeMessage = true; _stopBridgeBootstrap(); _lastBridgeSource = source; _lastBridgeAction = bridgeMessage.action; _lastBridgePayload = _formatPayload(bridgeMessage.payload); if (callbackPath.isNotEmpty) { _lastJsCallback = callbackPath; } _appendDebugLog( 'bridge', '$source -> ${bridgeMessage.action}${callbackPath.isEmpty ? '' : ' callback=$callbackPath'}${_lastBridgePayload.isEmpty ? '' : ' payload=$_lastBridgePayload'}', ); if (mounted) { setState(() {}); } } else if (rawMessage.trim().isNotEmpty) { _appendDebugLog('bridge', '$source -> raw=${rawMessage.trim()}'); } switch (bridgeMessage.action) { case BaishunBridgeActions.getConfig: await _sendConfigToGame( reason: 'get_config', jsCallbackPath: callbackPath.isEmpty ? null : callbackPath, ); break; case BaishunBridgeActions.gameLoaded: _appendDebugLog('loading', 'gameLoaded received'); if (!mounted) { return; } setState(() { _isLoading = false; _errorMessage = null; }); break; case BaishunBridgeActions.gameRecharge: _appendDebugLog('bridge', 'open recharge page'); await SCNavigatorUtils.push(context, WalletRoute.recharge); await _notifyWalletUpdate(); break; case BaishunBridgeActions.destroy: _appendDebugLog('bridge', 'destroy requested by H5'); await _closeAndExit(reason: 'h5_destroy'); break; default: break; } } Future _notifyWalletUpdate() async { _appendDebugLog('wallet', 'send walletUpdate to H5'); try { await _controller.runJavaScript( BaishunJsBridge.buildWalletUpdateScript(), ); } catch (_) {} } Future _reload() async { if (!mounted) { return; } _appendDebugLog('panel', 'manual reload'); await _loadGameEntry(); } Future _manualInjectBridge() async { await _injectBridge(reason: 'manual'); } Future _replayLastConfig() async { final callbackPath = _lastJsCallback.trim(); _appendDebugLog( 'panel', callbackPath.isEmpty ? 'manual replay config without jsCallback' : 'manual replay config to $callbackPath', ); await _sendConfigToGame( reason: 'manual_replay', jsCallbackPath: callbackPath.isEmpty ? null : callbackPath, force: true, ); } void _clearDebugLogs() { if (!mounted) { return; } setState(() { _debugLogs.clear(); }); _appendDebugLog('panel', 'logs cleared'); } Future _closeAndExit({String reason = 'page_back'}) async { if (_isClosing) { return; } _isClosing = true; try { if (!widget.launchModel.gameSessionId.startsWith('bs_mock_')) { await _repository.closeBaishunGame( roomId: widget.roomId, gameSessionId: widget.launchModel.gameSessionId, reason: reason, ); } } catch (_) {} if (mounted) { Navigator.of(context).pop(); } } String _extractCallbackPath(Map payload) { final candidates = [ payload['jsCallback']?.toString() ?? '', payload['callback']?.toString() ?? '', ]; for (final candidate in candidates) { final trimmed = candidate.trim(); if (trimmed.isNotEmpty) { return trimmed; } } return ''; } BaishunBridgeConfigModel _buildEffectiveBridgeConfig( BaishunBridgeConfigModel rawConfig, ) { final normalizedLanguage = _mapBaishunLanguage(rawConfig.language); if (normalizedLanguage == rawConfig.language.trim()) { return rawConfig; } return BaishunBridgeConfigModel( appName: rawConfig.appName, appChannel: rawConfig.appChannel, appId: rawConfig.appId, userId: rawConfig.userId, code: rawConfig.code, roomId: rawConfig.roomId, gameMode: rawConfig.gameMode, language: normalizedLanguage, gsp: rawConfig.gsp, gameConfig: rawConfig.gameConfig, ); } String _mapBaishunLanguage(String rawLanguage) { final trimmed = rawLanguage.trim(); if (trimmed.isEmpty) { return '2'; } final lower = trimmed.toLowerCase(); const supportedCodes = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', }; if (supportedCodes.contains(lower)) { return lower; } if (lower.startsWith('zh')) { return '0'; } switch (lower) { case 'en': case 'english': return '2'; case 'id': case 'in': case 'indonesian': return '3'; case 'ms': case 'malay': return '4'; case 'th': case 'thai': return '5'; case 'vi': case 'vietnamese': return '6'; case 'ar': case 'arabic': return '7'; case 'fil': case 'tl': case 'tagalog': return '8'; case 'pt': case 'portuguese': return '9'; case 'tr': case 'turkish': return '10'; case 'ur': case 'urdu': return '11'; default: return '2'; } } String _formatPayload(Map payload) { if (payload.isEmpty) { return ''; } try { return jsonEncode(payload); } catch (_) { return payload.toString(); } } void _appendDebugLog(String tag, String message) { const maxLogEntries = 80; final entry = BaishunDebugLogEntry( timestamp: DateTime.now(), tag: tag, message: message, ); if (!mounted) { _debugLogs.insert(0, entry); if (_debugLogs.length > maxLogEntries) { _debugLogs.removeRange(maxLogEntries, _debugLogs.length); } return; } setState(() { _debugLogs.insert(0, entry); if (_debugLogs.length > maxLogEntries) { _debugLogs.removeRange(maxLogEntries, _debugLogs.length); } }); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { if (didPop) { return; } await _closeAndExit(); }, child: ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(24.w), topRight: Radius.circular(24.w), ), child: Container( width: ScreenUtil().screenWidth, height: ScreenUtil().screenHeight * 0.8, color: const Color(0xFF081915), child: Column( children: [ SizedBox(height: 10.w), Container( width: 34.w, height: 4.w, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.28), borderRadius: BorderRadius.circular(99.w), ), ), SizedBox(height: 6.w), Padding( padding: EdgeInsets.symmetric(horizontal: 10.w), child: Row( children: [ IconButton( onPressed: _closeAndExit, icon: const Icon( Icons.arrow_back_ios, color: Colors.white, ), ), Expanded( child: Text( widget.game.name, textAlign: TextAlign.center, style: TextStyle( color: Colors.white, fontSize: 16.sp, fontWeight: FontWeight.w700, ), ), ), IconButton( onPressed: _closeAndExit, icon: const Icon(Icons.close, color: Colors.white), ), ], ), ), Expanded( child: Stack( children: [ Positioned.fill( child: WebViewWidget(controller: _controller), ), if (_errorMessage != null) _buildErrorState(), if (_isLoading && _errorMessage == null) const BaishunLoadingView( message: 'Waiting for gameLoaded...', ), Positioned( left: 12.w, bottom: 12.w, child: BaishunDebugPanel( snapshot: BaishunDebugSnapshot( roomId: widget.roomId, gameName: widget.game.name, gameId: widget.game.gameId, gameSessionId: widget.launchModel.gameSessionId, entryUrl: widget.launchModel.entry.entryUrl, launchMode: widget.launchModel.entry.launchMode, loading: _isLoading, pageFinished: _didFinishPageLoad, receivedBridgeMessage: _didReceiveBridgeMessage, injectCount: _bridgeInjectCount, configSendCount: _configSendCount, lastBridgeSource: _lastBridgeSource, lastBridgeAction: _lastBridgeAction, lastBridgePayload: _lastBridgePayload, lastJsCallback: _lastJsCallback, lastConfigSummary: _lastConfigSummary, errorMessage: _errorMessage ?? '', logs: List.unmodifiable( _debugLogs, ), ), onReload: _reload, onInjectBridge: _manualInjectBridge, onReplayConfig: _replayLastConfig, onWalletUpdate: _notifyWalletUpdate, onClearLogs: _clearDebugLogs, ), ), ], ), ), ], ), ), ), ); } 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( 'Game page 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 check game host and package url'); }, child: const Text('Tips'), ), ], ), ], ), ), ); } }