chatapp3-flutter/lib/modules/room_game/views/baishun_game_page.dart
2026-04-16 15:07:47 +08:00

721 lines
22 KiB
Dart

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<BaishunGamePage> createState() => _BaishunGamePageState();
}
class _BaishunGamePageState extends State<BaishunGamePage> {
final RoomGameRepository _repository = RoomGameRepository();
late final WebViewController _controller;
Timer? _bridgeBootstrapTimer;
Timer? _loadingFallbackTimer;
final List<BaishunDebugLogEntry> _debugLogs = <BaishunDebugLogEntry>[];
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<void> _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<void> _injectBridge({String reason = 'manual'}) async {
_bridgeInjectCount += 1;
if (reason != 'bootstrap') {
_appendDebugLog('inject', 'reason=$reason count=$_bridgeInjectCount');
}
try {
await _controller.runJavaScript(BaishunJsBridge.bootstrapScript());
} catch (_) {}
}
Future<void> _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<void> _handleBridgeMessage(JavaScriptMessage message) async {
final bridgeMessage = BaishunBridgeMessage.parse(message.message);
await _dispatchBridgeMessage(
bridgeMessage,
source: BaishunJsBridge.channelName,
rawMessage: message.message,
);
}
Future<void> _handleNamedChannelMessage(
String action,
JavaScriptMessage message,
) async {
final bridgeMessage = BaishunBridgeMessage.fromAction(
action,
message.message,
);
await _dispatchBridgeMessage(
bridgeMessage,
source: 'channel:$action',
rawMessage: message.message,
);
}
Future<void> _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<void> _notifyWalletUpdate() async {
_appendDebugLog('wallet', 'send walletUpdate to H5');
try {
await _controller.runJavaScript(
BaishunJsBridge.buildWalletUpdateScript(),
);
} catch (_) {}
}
Future<void> _reload() async {
if (!mounted) {
return;
}
_appendDebugLog('panel', 'manual reload');
await _loadGameEntry();
}
Future<void> _manualInjectBridge() async {
await _injectBridge(reason: 'manual');
}
Future<void> _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<void> _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<String, dynamic> payload) {
final candidates = <String>[
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 = <String>{
'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<String, dynamic> 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<BaishunDebugLogEntry>.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'),
),
],
),
],
),
),
);
}
}