chatapp3-flutter/lib/modules/room_game/views/baishun_game_page.dart
2026-04-16 12:11:21 +08:00

345 lines
10 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
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_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;
bool _isLoading = true;
bool _isClosing = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_controller =
WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.black)
..addJavaScriptChannel(
BaishunJsBridge.channelName,
onMessageReceived: _handleBridgeMessage,
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (String _) async {
await _primeBridgeHandshake();
},
onWebResourceError: (WebResourceError error) {
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.description;
_isLoading = false;
});
},
),
);
unawaited(_loadGameEntry());
}
Future<void> _loadGameEntry() async {
try {
final entryUrl = widget.launchModel.entry.entryUrl.trim();
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');
}
final html = await _fetchRemoteEntryHtml(uri);
final injectedHtml = BaishunJsBridge.injectBootstrapHtml(
html: html,
entryUri: uri,
config: widget.launchModel.bridgeConfig,
);
await _controller.loadHtmlString(injectedHtml, baseUrl: uri.toString());
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_errorMessage = error.toString();
_isLoading = false;
});
}
}
Future<String> _fetchRemoteEntryHtml(Uri uri) async {
final client = HttpClient();
client.userAgent = 'YumiBaishunBridge/1.0';
try {
final request = await client.getUrl(uri);
final response = await request.close();
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Failed to load remote entry html (${response.statusCode})',
uri: uri,
);
}
return response.transform(utf8.decoder).join();
} finally {
client.close(force: true);
}
}
Future<void> _injectBridge() async {
try {
await _controller.runJavaScript(BaishunJsBridge.bootstrapScript());
await _controller.runJavaScript(
BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig),
);
} catch (_) {}
}
Future<void> _primeBridgeHandshake() async {
await _injectBridge();
for (final delay in <Duration>[
const Duration(milliseconds: 400),
const Duration(milliseconds: 1200),
]) {
Future<void>.delayed(delay, () async {
if (!mounted || !_isLoading || _errorMessage != null) {
return;
}
await _injectBridge();
});
}
}
Future<void> _handleBridgeMessage(JavaScriptMessage message) async {
final bridgeMessage = BaishunBridgeMessage.parse(message.message);
switch (bridgeMessage.action) {
case BaishunBridgeActions.getConfig:
await _controller.runJavaScript(
BaishunJsBridge.buildConfigScript(widget.launchModel.bridgeConfig),
);
break;
case BaishunBridgeActions.gameLoaded:
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_errorMessage = null;
});
break;
case BaishunBridgeActions.gameRecharge:
await SCNavigatorUtils.push(context, WalletRoute.recharge);
await _notifyWalletUpdate();
break;
case BaishunBridgeActions.destroy:
await _closeAndExit(reason: 'h5_destroy');
break;
default:
break;
}
}
Future<void> _notifyWalletUpdate() async {
try {
await _controller.runJavaScript(
BaishunJsBridge.buildWalletUpdateScript(),
);
} catch (_) {}
}
Future<void> _reload() async {
if (!mounted) {
return;
}
setState(() {
_errorMessage = null;
_isLoading = true;
});
await _loadGameEntry();
}
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();
}
}
@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 IgnorePointer(
ignoring: true,
child: BaishunLoadingView(
message: 'Waiting for gameLoaded...',
),
),
],
),
),
],
),
),
),
);
}
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'),
),
],
),
],
),
),
);
}
}