1125 lines
37 KiB
Dart
1125 lines
37 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:webview_windows/webview_windows.dart';
|
|
import 'package:window_manager/window_manager.dart';
|
|
|
|
const int kControlPort = 18961;
|
|
const Size kWindowSize = Size(1280, 820);
|
|
const String kWindowTitle = '浮动小游戏';
|
|
const String kDefaultServerBase = 'http://127.0.0.1:8080';
|
|
const String kAppFontFamily = 'Bansonic';
|
|
|
|
Future<void> main(List<String> args) async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
await windowManager.ensureInitialized();
|
|
|
|
final config = _parseLaunchConfig(args);
|
|
const windowOptions = WindowOptions(
|
|
size: kWindowSize,
|
|
center: true,
|
|
backgroundColor: Colors.transparent,
|
|
skipTaskbar: false,
|
|
titleBarStyle: TitleBarStyle.normal,
|
|
title: kWindowTitle,
|
|
);
|
|
|
|
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
|
await windowManager.setTitle(kWindowTitle);
|
|
await windowManager.show();
|
|
await windowManager.focus();
|
|
});
|
|
|
|
runApp(FloatingMiniGamesApp(config: config));
|
|
}
|
|
|
|
AppLaunchConfig _parseLaunchConfig(List<String> args) {
|
|
String serverBase = kDefaultServerBase;
|
|
String? initialUrl;
|
|
|
|
for (final rawArg in args) {
|
|
final arg = rawArg.trim();
|
|
if (arg.isEmpty) {
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--server-base=')) {
|
|
final value = arg.substring('--server-base='.length).trim();
|
|
if (value.isNotEmpty) {
|
|
serverBase = _normalizeServerBase(value);
|
|
}
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--open-url=')) {
|
|
final value = arg.substring('--open-url='.length).trim();
|
|
if (value.isNotEmpty) {
|
|
initialUrl = _normalizeOpenTarget(value);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
initialUrl = _normalizeOpenTarget(arg);
|
|
}
|
|
|
|
return AppLaunchConfig(
|
|
serverBase: _normalizeServerBase(serverBase),
|
|
initialUrl: initialUrl,
|
|
);
|
|
}
|
|
|
|
String _normalizeServerBase(String raw) {
|
|
final uri = _parseServerBaseUri(raw);
|
|
return '${uri.scheme}://${uri.authority}';
|
|
}
|
|
|
|
Uri _parseServerBaseUri(String raw) {
|
|
final trimmed = raw.trim().replaceAll('"', '').replaceAll("'", '');
|
|
if (trimmed.isEmpty) {
|
|
return Uri.parse(kDefaultServerBase);
|
|
}
|
|
|
|
Uri? parsed = Uri.tryParse(trimmed);
|
|
if (parsed != null && parsed.hasScheme && parsed.host.isNotEmpty) {
|
|
return parsed.replace(path: '', query: '', fragment: '');
|
|
}
|
|
|
|
parsed = Uri.tryParse('http://$trimmed');
|
|
if (parsed != null && parsed.host.isNotEmpty) {
|
|
return parsed.replace(path: '', query: '', fragment: '');
|
|
}
|
|
|
|
return Uri.parse(kDefaultServerBase);
|
|
}
|
|
|
|
Uri _buildServerUri(String serverBase, String path) {
|
|
final base = _parseServerBaseUri(serverBase);
|
|
final normalizedPath = path.startsWith('/') ? path : '/$path';
|
|
return base.replace(path: normalizedPath, query: null, fragment: null);
|
|
}
|
|
|
|
String _normalizeOpenTarget(String raw) {
|
|
final trimmed = raw.trim();
|
|
if (trimmed.isEmpty) {
|
|
return trimmed;
|
|
}
|
|
|
|
final uri = Uri.tryParse(trimmed);
|
|
if (uri != null && uri.hasScheme) {
|
|
return uri.toString();
|
|
}
|
|
|
|
final file = File(trimmed);
|
|
if (file.existsSync()) {
|
|
return file.absolute.uri.toString();
|
|
}
|
|
|
|
if (trimmed.startsWith(r'\\') || RegExp(r'^[A-Za-z]:[\\/]').hasMatch(trimmed)) {
|
|
return File(trimmed).absolute.uri.toString();
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
class AppLaunchConfig {
|
|
const AppLaunchConfig({required this.serverBase, this.initialUrl});
|
|
|
|
final String serverBase;
|
|
final String? initialUrl;
|
|
}
|
|
|
|
class MiniGameRecord {
|
|
const MiniGameRecord({
|
|
required this.id,
|
|
required this.name,
|
|
required this.gameType,
|
|
required this.publisher,
|
|
required this.publishDate,
|
|
required this.description,
|
|
required this.playUrl,
|
|
});
|
|
|
|
final int id;
|
|
final String name;
|
|
final String gameType;
|
|
final String publisher;
|
|
final String publishDate;
|
|
final String description;
|
|
final String playUrl;
|
|
|
|
factory MiniGameRecord.fromJson(Map<String, dynamic> json, String serverBase) {
|
|
final rawPlayUrl = (json['play_url'] ?? '').toString().trim();
|
|
return MiniGameRecord(
|
|
id: int.tryParse((json['id'] ?? '0').toString()) ?? 0,
|
|
name: (json['name'] ?? '').toString(),
|
|
gameType: ((json['game_type'] ?? 'official').toString().trim().toLowerCase() == 'community') ? 'community' : 'official',
|
|
publisher: (json['publisher'] ?? '').toString(),
|
|
publishDate: (json['publish_date'] ?? '').toString(),
|
|
description: (json['description'] ?? '').toString(),
|
|
playUrl: _absolutizePlayUrl(serverBase, rawPlayUrl),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _absolutizePlayUrl(String serverBase, String playUrl) {
|
|
if (playUrl.isEmpty) {
|
|
return playUrl;
|
|
}
|
|
final uri = Uri.tryParse(playUrl);
|
|
if (uri != null && uri.hasScheme) {
|
|
return uri.toString();
|
|
}
|
|
final slashNormalized = playUrl.startsWith('/') ? playUrl : '/$playUrl';
|
|
return '${serverBase.replaceAll(RegExp(r'/+$'), '')}$slashNormalized';
|
|
}
|
|
|
|
class FloatingMiniGamesApp extends StatelessWidget {
|
|
const FloatingMiniGamesApp({super.key, required this.config});
|
|
|
|
final AppLaunchConfig config;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final baseTheme = ThemeData(
|
|
useMaterial3: true,
|
|
brightness: Brightness.dark,
|
|
fontFamily: kAppFontFamily,
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: const Color(0xFF59C6FF),
|
|
brightness: Brightness.dark,
|
|
),
|
|
scaffoldBackgroundColor: const Color(0xFF0B0D14),
|
|
snackBarTheme: const SnackBarThemeData(
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
|
|
return MaterialApp(
|
|
title: kWindowTitle,
|
|
debugShowCheckedModeBanner: false,
|
|
theme: baseTheme,
|
|
home: MiniGamesShell(config: config),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MiniGamesShell extends StatefulWidget {
|
|
const MiniGamesShell({super.key, required this.config});
|
|
|
|
final AppLaunchConfig config;
|
|
|
|
@override
|
|
State<MiniGamesShell> createState() => _MiniGamesShellState();
|
|
}
|
|
|
|
class _MiniGamesShellState extends State<MiniGamesShell> {
|
|
final WebviewController _controller = WebviewController();
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
HttpServer? _controlServer;
|
|
StreamSubscription<String>? _titleSubscription;
|
|
StreamSubscription<LoadingState>? _loadingSubscription;
|
|
StreamSubscription<String>? _urlSubscription;
|
|
StreamSubscription<HistoryChanged>? _historySubscription;
|
|
StreamSubscription<WebErrorStatus>? _loadErrorSubscription;
|
|
StreamSubscription<dynamic>? _webMessageSubscription;
|
|
Timer? _latencyTimer;
|
|
|
|
bool _initialized = false;
|
|
bool _isLoading = false;
|
|
bool _showHome = true;
|
|
bool _gamesLoading = true;
|
|
bool _canGoBack = false;
|
|
bool _canGoForward = false;
|
|
bool _disposed = false;
|
|
|
|
String _statusText = '正在初始化浏览器';
|
|
String _currentLabel = '小游戏大厅';
|
|
String _currentUrl = '';
|
|
String _homeError = '';
|
|
String _searchQuery = '';
|
|
String _typeFilter = 'all';
|
|
String? _queuedOpenUrl;
|
|
int? _latencyMs;
|
|
List<MiniGameRecord> _games = const [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_bootstrap();
|
|
}
|
|
|
|
Future<void> _bootstrap() async {
|
|
try {
|
|
_searchController.addListener(() {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_searchQuery = _searchController.text.trim().toLowerCase();
|
|
});
|
|
});
|
|
await _controller.initialize();
|
|
await _controller.setBackgroundColor(const Color(0xFF0B0D14));
|
|
await _controller.setPopupWindowPolicy(WebviewPopupWindowPolicy.deny);
|
|
await _installBrowserGuards();
|
|
_bindControllerStreams();
|
|
await _startControlServer();
|
|
await _refreshLatency();
|
|
_latencyTimer = Timer.periodic(const Duration(seconds: 15), (_) => _refreshLatency());
|
|
await _loadMiniGames();
|
|
if (widget.config.initialUrl != null && widget.config.initialUrl!.isNotEmpty) {
|
|
await _openUrl(widget.config.initialUrl!, label: '外部页面');
|
|
}
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_initialized = true;
|
|
_statusText = '网页已加载';
|
|
});
|
|
if (_queuedOpenUrl != null && _queuedOpenUrl!.isNotEmpty) {
|
|
final pending = _queuedOpenUrl!;
|
|
_queuedOpenUrl = null;
|
|
await _openUrl(pending, label: '外部页面');
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_homeError = '初始化失败:$error';
|
|
_gamesLoading = false;
|
|
_statusText = '初始化失败';
|
|
});
|
|
}
|
|
}
|
|
|
|
void _bindControllerStreams() {
|
|
_loadingSubscription = _controller.loadingState.listen((state) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isLoading = state != LoadingState.navigationCompleted;
|
|
if (!_showHome) {
|
|
_statusText = _isLoading ? '页面加载中' : '页面已就绪';
|
|
}
|
|
});
|
|
});
|
|
|
|
_urlSubscription = _controller.url.listen((url) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_currentUrl = url;
|
|
});
|
|
});
|
|
|
|
_titleSubscription = _controller.title.listen((title) {
|
|
if (!mounted || title.trim().isEmpty) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
if (!_showHome) {
|
|
_currentLabel = title.trim();
|
|
}
|
|
});
|
|
});
|
|
|
|
_historySubscription = _controller.historyChanged.listen((history) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_canGoBack = history.canGoBack;
|
|
_canGoForward = history.canGoForward;
|
|
});
|
|
});
|
|
|
|
_loadErrorSubscription = _controller.onLoadError.listen((status) {
|
|
_showNotice('页面加载失败:${_describeWebError(status)}');
|
|
});
|
|
|
|
_webMessageSubscription = _controller.webMessage.listen((dynamic message) {
|
|
if (message is Map) {
|
|
_handleBrowserMessage(message.map((key, value) => MapEntry(key.toString(), value)));
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _installBrowserGuards() async {
|
|
await _controller.addScriptToExecuteOnDocumentCreated('''
|
|
(() => {
|
|
const send = (payload) => {
|
|
try {
|
|
if (window.chrome && window.chrome.webview) {
|
|
window.chrome.webview.postMessage(JSON.stringify(payload));
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
|
|
const blockKeys = (event) => {
|
|
const key = (event.key || '').toLowerCase();
|
|
const isF12 = key === 'f12';
|
|
const isDevToolsCombo = event.ctrlKey && event.shiftKey && ['i', 'j', 'c'].includes(key);
|
|
const isSourceCombo = event.ctrlKey && key === 'u';
|
|
if (isF12 || isDevToolsCombo || isSourceCombo) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
send({ kind: 'blocked-shortcut', key: key });
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
document.addEventListener('keydown', blockKeys, true);
|
|
document.addEventListener('contextmenu', (event) => event.preventDefault(), true);
|
|
document.addEventListener('wheel', (event) => {
|
|
if (event.ctrlKey) {
|
|
event.preventDefault();
|
|
}
|
|
}, { passive: false, capture: true });
|
|
|
|
window.addEventListener('error', (event) => {
|
|
send({
|
|
kind: 'js-error',
|
|
message: event.message || '脚本异常',
|
|
source: event.filename || '',
|
|
line: event.lineno || 0,
|
|
column: event.colno || 0
|
|
});
|
|
}, true);
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const reason = event && event.reason ? (event.reason.message || String(event.reason)) : 'Promise 未处理异常';
|
|
send({ kind: 'promise-error', message: reason });
|
|
});
|
|
|
|
const originalFetch = window.fetch ? window.fetch.bind(window) : null;
|
|
if (originalFetch) {
|
|
window.fetch = async (...args) => {
|
|
try {
|
|
const response = await originalFetch(...args);
|
|
if (!response.ok) {
|
|
send({ kind: 'http-error', url: response.url || '', status: response.status, statusText: response.statusText || '' });
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
send({ kind: 'request-error', url: String(args[0] || ''), message: error && error.message ? error.message : String(error) });
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
this.__fdRequestedUrl = url;
|
|
return originalOpen.call(this, method, url, ...rest);
|
|
};
|
|
|
|
const originalSend = XMLHttpRequest.prototype.send;
|
|
XMLHttpRequest.prototype.send = function(...args) {
|
|
this.addEventListener('error', () => {
|
|
send({ kind: 'request-error', url: String(this.__fdRequestedUrl || ''), message: 'XMLHttpRequest 失败' });
|
|
});
|
|
this.addEventListener('loadend', () => {
|
|
if (this.status >= 400) {
|
|
send({ kind: 'http-error', url: String(this.responseURL || this.__fdRequestedUrl || ''), status: this.status, statusText: this.statusText || '' });
|
|
}
|
|
});
|
|
return originalSend.apply(this, args);
|
|
};
|
|
})();
|
|
''');
|
|
}
|
|
|
|
Future<void> _startControlServer() async {
|
|
_controlServer = await HttpServer.bind(InternetAddress.loopbackIPv4, kControlPort);
|
|
unawaited(_listenForControlRequests(_controlServer!));
|
|
}
|
|
|
|
Future<void> _listenForControlRequests(HttpServer server) async {
|
|
await for (final request in server) {
|
|
try {
|
|
final path = request.uri.path;
|
|
if (request.method == 'GET' && path == '/health') {
|
|
await _writeJson(request.response, HttpStatus.ok, <String, Object?>{
|
|
'ok': true,
|
|
'initialized': _initialized,
|
|
'showHome': _showHome,
|
|
'status': _statusText,
|
|
'latencyMs': _latencyMs,
|
|
'currentUrl': _currentUrl,
|
|
'serverBase': widget.config.serverBase,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (path == '/open' && (request.method == 'POST' || request.method == 'GET')) {
|
|
final targetUrl = await _extractUrl(request);
|
|
if (targetUrl == null || targetUrl.trim().isEmpty) {
|
|
await _writeJson(request.response, HttpStatus.badRequest, {'ok': false, 'message': 'missing url'});
|
|
continue;
|
|
}
|
|
final normalized = _normalizeOpenTarget(targetUrl);
|
|
if (!_initialized) {
|
|
_queuedOpenUrl = normalized;
|
|
await _writeJson(request.response, HttpStatus.accepted, {'ok': true, 'queued': true, 'url': normalized});
|
|
continue;
|
|
}
|
|
await _openUrl(normalized, label: '外部页面');
|
|
await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'queued': false, 'url': normalized});
|
|
continue;
|
|
}
|
|
|
|
if (path == '/home' && (request.method == 'POST' || request.method == 'GET')) {
|
|
await _goHome();
|
|
await _writeJson(request.response, HttpStatus.ok, {'ok': true});
|
|
continue;
|
|
}
|
|
|
|
if (path == '/back' && (request.method == 'POST' || request.method == 'GET')) {
|
|
if (_canGoBack && !_showHome) {
|
|
await _controller.goBack();
|
|
}
|
|
await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'canGoBack': _canGoBack});
|
|
continue;
|
|
}
|
|
|
|
if (path == '/forward' && (request.method == 'POST' || request.method == 'GET')) {
|
|
if (_canGoForward && !_showHome) {
|
|
await _controller.goForward();
|
|
}
|
|
await _writeJson(request.response, HttpStatus.ok, {'ok': true, 'canGoForward': _canGoForward});
|
|
continue;
|
|
}
|
|
|
|
if (path == '/refresh' && (request.method == 'POST' || request.method == 'GET')) {
|
|
await _refreshCurrent();
|
|
await _writeJson(request.response, HttpStatus.ok, {'ok': true});
|
|
continue;
|
|
}
|
|
|
|
await _writeJson(request.response, HttpStatus.notFound, {'ok': false, 'message': 'not found'});
|
|
} catch (error) {
|
|
await _writeJson(request.response, HttpStatus.internalServerError, {'ok': false, 'message': error.toString()});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String?> _extractUrl(HttpRequest request) async {
|
|
if (request.method == 'GET') {
|
|
return request.uri.queryParameters['url'];
|
|
}
|
|
|
|
final body = await utf8.decoder.bind(request).join();
|
|
if (body.trim().isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
final decoded = jsonDecode(body);
|
|
if (decoded is Map<String, dynamic>) {
|
|
return decoded['url']?.toString();
|
|
}
|
|
if (decoded is Map) {
|
|
return decoded['url']?.toString();
|
|
}
|
|
} catch (_) {
|
|
return body.trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<void> _writeJson(HttpResponse response, int statusCode, Map<String, Object?> payload) async {
|
|
response.statusCode = statusCode;
|
|
response.headers.contentType = ContentType.json;
|
|
response.write(jsonEncode(payload));
|
|
await response.close();
|
|
}
|
|
|
|
Future<void> _refreshLatency() async {
|
|
final stopwatch = Stopwatch()..start();
|
|
try {
|
|
final uri = _buildServerUri(widget.config.serverBase, '/api/ping');
|
|
final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
|
|
try {
|
|
final request = await client.getUrl(uri);
|
|
final response = await request.close().timeout(const Duration(seconds: 5));
|
|
await response.drain<void>();
|
|
stopwatch.stop();
|
|
if (mounted) {
|
|
setState(() {
|
|
_latencyMs = stopwatch.elapsedMilliseconds;
|
|
});
|
|
}
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_latencyMs = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMiniGames() async {
|
|
if (mounted) {
|
|
setState(() {
|
|
_gamesLoading = true;
|
|
_homeError = '';
|
|
});
|
|
}
|
|
|
|
try {
|
|
final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
|
|
try {
|
|
final candidates = <String>[
|
|
_buildServerUri(widget.config.serverBase, '/api/mini-games').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/api/minigames').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/api/easy-games').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/mini-games/list').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/minigames').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/easy-games/list').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/easygames').toString(),
|
|
_buildServerUri(widget.config.serverBase, '/admin/api/mini-games').toString(),
|
|
];
|
|
|
|
HttpException? lastHttpError;
|
|
String body = '';
|
|
int statusCode = 0;
|
|
Map<String, dynamic>? decoded;
|
|
for (final candidate in candidates) {
|
|
final request = await client.getUrl(Uri.parse(candidate));
|
|
final response = await request.close().timeout(const Duration(seconds: 8));
|
|
body = await utf8.decoder.bind(response).join();
|
|
statusCode = response.statusCode;
|
|
if (response.statusCode == HttpStatus.ok) {
|
|
final rawDecoded = jsonDecode(body);
|
|
if (rawDecoded is Map<String, dynamic>) {
|
|
decoded = rawDecoded;
|
|
break;
|
|
}
|
|
if (rawDecoded is Map) {
|
|
decoded = rawDecoded.cast<String, dynamic>();
|
|
break;
|
|
}
|
|
throw HttpException('HTTP 200: invalid payload');
|
|
}
|
|
lastHttpError = HttpException('HTTP ${response.statusCode}: $body');
|
|
if (response.statusCode != HttpStatus.notFound) {
|
|
break;
|
|
}
|
|
}
|
|
if (decoded == null) {
|
|
throw lastHttpError ?? HttpException('HTTP $statusCode: $body');
|
|
}
|
|
final list = decoded['games'];
|
|
final items = <MiniGameRecord>[];
|
|
if (list is List) {
|
|
for (final entry in list) {
|
|
if (entry is Map) {
|
|
items.add(MiniGameRecord.fromJson(entry.cast<String, dynamic>(), widget.config.serverBase));
|
|
}
|
|
}
|
|
}
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_games = items;
|
|
_gamesLoading = false;
|
|
_statusText = '小游戏大厅已同步';
|
|
});
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_gamesLoading = false;
|
|
_homeError = '无法拉取小游戏列表:$error';
|
|
_statusText = '无法访问小游戏服务';
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _openGame(MiniGameRecord game) async {
|
|
await _openUrl(game.playUrl, label: game.name);
|
|
}
|
|
|
|
Future<void> _openUrl(String rawUrl, {String? label}) async {
|
|
final normalized = _normalizeOpenTarget(rawUrl);
|
|
if (!_initialized) {
|
|
_queuedOpenUrl = normalized;
|
|
return;
|
|
}
|
|
await _controller.loadUrl(normalized);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_showHome = false;
|
|
_isLoading = true;
|
|
_currentUrl = normalized;
|
|
_currentLabel = (label == null || label.trim().isEmpty) ? '网页内容' : label.trim();
|
|
_statusText = '正在打开页面';
|
|
});
|
|
await windowManager.show();
|
|
await windowManager.focus();
|
|
}
|
|
|
|
Future<void> _goHome() async {
|
|
if (!_showHome) {
|
|
try {
|
|
await _controller.stop();
|
|
} catch (_) {}
|
|
}
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_showHome = true;
|
|
_isLoading = false;
|
|
_canGoBack = false;
|
|
_canGoForward = false;
|
|
_currentUrl = '';
|
|
_currentLabel = '小游戏大厅';
|
|
_statusText = '小游戏大厅已就绪';
|
|
});
|
|
await _loadMiniGames();
|
|
}
|
|
|
|
Future<void> _refreshCurrent() async {
|
|
if (_showHome) {
|
|
await _loadMiniGames();
|
|
return;
|
|
}
|
|
try {
|
|
await _controller.reload();
|
|
} catch (_) {}
|
|
}
|
|
|
|
String _describeWebError(WebErrorStatus status) {
|
|
switch (status) {
|
|
case WebErrorStatus.WebErrorStatusTimeout:
|
|
return '请求超时';
|
|
case WebErrorStatus.WebErrorStatusConnectionAborted:
|
|
return '连接被中止';
|
|
case WebErrorStatus.WebErrorStatusConnectionReset:
|
|
return '连接被重置';
|
|
case WebErrorStatus.WebErrorStatusCannotConnect:
|
|
return '连接失败';
|
|
case WebErrorStatus.WebErrorStatusDisconnected:
|
|
return '网络已断开';
|
|
case WebErrorStatus.WebErrorStatusHostNameNotResolved:
|
|
return '无法解析主机名';
|
|
case WebErrorStatus.WebErrorStatusServerUnreachable:
|
|
return '服务器不可达';
|
|
case WebErrorStatus.WebErrorStatusErrorHTTPInvalidServerResponse:
|
|
return '服务器响应无效';
|
|
default:
|
|
return status.name;
|
|
}
|
|
}
|
|
|
|
void _handleBrowserMessage(Map<String, dynamic> message) {
|
|
final kind = (message['kind'] ?? '').toString();
|
|
switch (kind) {
|
|
case 'blocked-shortcut':
|
|
_showNotice('已禁用开发者快捷键');
|
|
break;
|
|
case 'js-error':
|
|
_showNotice('页面脚本异常:${message['message'] ?? '未知错误'}');
|
|
break;
|
|
case 'promise-error':
|
|
_showNotice('页面 Promise 异常:${message['message'] ?? '未知错误'}');
|
|
break;
|
|
case 'http-error':
|
|
final status = message['status']?.toString() ?? '--';
|
|
final url = message['url']?.toString() ?? '';
|
|
_showNotice('页面请求失败:HTTP $status${url.isEmpty ? '' : ' · $url'}');
|
|
break;
|
|
case 'request-error':
|
|
final url = message['url']?.toString() ?? '';
|
|
final detail = message['message']?.toString() ?? '网络请求异常';
|
|
_showNotice('页面请求异常:$detail${url.isEmpty ? '' : ' · $url'}');
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _showNotice(String message) {
|
|
if (!mounted || _disposed) {
|
|
return;
|
|
}
|
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
messenger?.hideCurrentSnackBar();
|
|
messenger?.showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
duration: const Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
_searchController.dispose();
|
|
_latencyTimer?.cancel();
|
|
_titleSubscription?.cancel();
|
|
_loadingSubscription?.cancel();
|
|
_urlSubscription?.cancel();
|
|
_historySubscription?.cancel();
|
|
_loadErrorSubscription?.cancel();
|
|
_webMessageSubscription?.cancel();
|
|
_controlServer?.close(force: true);
|
|
unawaited(_controller.dispose());
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final latencyText = _latencyMs == null ? '延迟 --' : '延迟 $_latencyMs ms';
|
|
return Scaffold(
|
|
body: Column(
|
|
children: [
|
|
Container(
|
|
height: 64,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFF121826),
|
|
border: Border(bottom: BorderSide(color: Color(0x222D4B73))),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Image.asset(
|
|
'assets/bansonic icon.png',
|
|
width: 36,
|
|
height: 36,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
kWindowTitle,
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
|
),
|
|
Text(
|
|
_currentLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFFAFC2DE)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
latencyText,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF8FB3D9)),
|
|
),
|
|
const SizedBox(width: 10),
|
|
IconButton(
|
|
tooltip: '刷新',
|
|
onPressed: _refreshCurrent,
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
),
|
|
IconButton(
|
|
tooltip: '后退',
|
|
onPressed: (!_showHome && _canGoBack) ? () => _controller.goBack() : null,
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
),
|
|
IconButton(
|
|
tooltip: '前进',
|
|
onPressed: (!_showHome && _canGoForward) ? () => _controller.goForward() : null,
|
|
icon: const Icon(Icons.arrow_forward_rounded),
|
|
),
|
|
IconButton(
|
|
tooltip: '回到主页',
|
|
onPressed: _showHome ? null : _goHome,
|
|
icon: const Icon(Icons.home_rounded),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 180),
|
|
child: _showHome ? _buildHomeView() : Webview(_controller),
|
|
),
|
|
),
|
|
if (_isLoading && !_showHome)
|
|
const Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHomeView() {
|
|
final filteredGames = _games.where((game) {
|
|
if (_typeFilter != 'all' && game.gameType != _typeFilter) {
|
|
return false;
|
|
}
|
|
if (_searchQuery.isEmpty) {
|
|
return true;
|
|
}
|
|
final haystack = '${game.id} ${game.name} ${game.publisher} ${game.description}'.toLowerCase();
|
|
return haystack.contains(_searchQuery);
|
|
}).toList();
|
|
|
|
return Container(
|
|
key: const ValueKey<String>('home-view'),
|
|
color: const Color(0xFF0B0D14),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 10),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF151E2E),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: const Color(0x223F628D)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.sports_esports_rounded, color: Color(0xFF71D4FF)),
|
|
const SizedBox(width: 10),
|
|
const Expanded(
|
|
child: Text(
|
|
'已连接小游戏大厅。这里展示服务端已发布的 H5 小游戏。',
|
|
style: TextStyle(fontSize: 13, color: Color(0xFFD6E5F7)),
|
|
),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: _gamesLoading ? null : _loadMiniGames,
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
label: const Text('刷新'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: '搜索 ID / 名称 / 发布者',
|
|
prefixIcon: const Icon(Icons.search_rounded),
|
|
filled: true,
|
|
fillColor: const Color(0xFF151E2E),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: const BorderSide(color: Color(0x223F628D)),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: const BorderSide(color: Color(0x223F628D)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF151E2E),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: const Color(0x223F628D)),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _typeFilter,
|
|
dropdownColor: const Color(0xFF151E2E),
|
|
items: const [
|
|
DropdownMenuItem(value: 'all', child: Text('全部类型')),
|
|
DropdownMenuItem(value: 'official', child: Text('官方')),
|
|
DropdownMenuItem(value: 'community', child: Text('社区')),
|
|
],
|
|
onChanged: (value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_typeFilter = value;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Builder(
|
|
builder: (context) {
|
|
if (_gamesLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (_homeError.isNotEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.cloud_off_rounded, size: 56, color: Color(0xFF6E8BA9)),
|
|
const SizedBox(height: 16),
|
|
Text(_homeError, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFC7D6EA))),
|
|
const SizedBox(height: 16),
|
|
FilledButton(onPressed: _loadMiniGames, child: const Text('重新尝试')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (_games.isEmpty) {
|
|
return const Center(
|
|
child: Text('当前没有已发布的小游戏', style: TextStyle(color: Color(0xFFAFC2DE))),
|
|
);
|
|
}
|
|
if (filteredGames.isEmpty) {
|
|
return const Center(
|
|
child: Text('没有符合条件的小游戏', style: TextStyle(color: Color(0xFFAFC2DE))),
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
itemCount: filteredGames.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
|
itemBuilder: (context, index) {
|
|
final game = filteredGames[index];
|
|
return _MiniGameCard(
|
|
game: game,
|
|
onPlay: () => _openGame(game),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MiniGameCard extends StatelessWidget {
|
|
const _MiniGameCard({required this.game, required this.onPlay});
|
|
|
|
final MiniGameRecord game;
|
|
final VoidCallback onPlay;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF151E2E),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: const Color(0x223F628D)),
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(16),
|
|
onTap: onPlay,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 68,
|
|
height: 68,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF20314A),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: const Icon(Icons.extension_rounded, size: 34, color: Color(0xFF7ED3FF)),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(game.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
game.description.isEmpty ? '暂无简介' : game.description,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFFAFC2DE)),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_MetaChip(label: 'ID ${game.id}'),
|
|
_MetaChip(label: game.gameType == 'community' ? '社区' : '官方'),
|
|
_MetaChip(label: game.publisher.isEmpty ? '发布者未填写' : game.publisher),
|
|
_MetaChip(label: game.publishDate.isEmpty ? '未设置发布时间' : game.publishDate),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
FilledButton.icon(
|
|
onPressed: onPlay,
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
label: const Text('开玩'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetaChip extends StatelessWidget {
|
|
const _MetaChip({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF20314A),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFFD6E5F7))),
|
|
);
|
|
}
|
|
}
|