315 lines
8.8 KiB
Dart
315 lines
8.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
|
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
|
|
|
const _homeUrl = 'https://h5-im.imharry.work/';
|
|
const _stopWebMediaScript = r'''
|
|
(() => {
|
|
try {
|
|
window.__stopOpenIMVoicePlayback?.();
|
|
} catch (_) {}
|
|
document.querySelectorAll('audio, video').forEach((media) => {
|
|
try {
|
|
media.pause();
|
|
media.currentTime = 0;
|
|
} catch (_) {}
|
|
});
|
|
})();
|
|
''';
|
|
|
|
void main() {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
const SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
statusBarIconBrightness: Brightness.dark,
|
|
systemNavigationBarColor: Colors.white,
|
|
systemNavigationBarIconBrightness: Brightness.dark,
|
|
),
|
|
);
|
|
runApp(const ImWebViewApp());
|
|
}
|
|
|
|
class ImWebViewApp extends StatelessWidget {
|
|
const ImWebViewApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: '集中营',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FEB)),
|
|
scaffoldBackgroundColor: Colors.white,
|
|
useMaterial3: true,
|
|
),
|
|
home: const H5ShellPage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class H5ShellPage extends StatefulWidget {
|
|
const H5ShellPage({super.key});
|
|
|
|
@override
|
|
State<H5ShellPage> createState() => _H5ShellPageState();
|
|
}
|
|
|
|
class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
|
late final WebViewController _controller;
|
|
|
|
int _progress = 0;
|
|
String? _loadError;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_controller = _buildController()..loadRequest(Uri.parse(_homeUrl));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.hidden ||
|
|
state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.detached) {
|
|
unawaited(_stopWebMedia());
|
|
}
|
|
}
|
|
|
|
WebViewController _buildController() {
|
|
PlatformWebViewControllerCreationParams params =
|
|
const PlatformWebViewControllerCreationParams();
|
|
|
|
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
|
params = WebKitWebViewControllerCreationParams(
|
|
allowsInlineMediaPlayback: true,
|
|
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
|
|
);
|
|
} else if (WebViewPlatform.instance is AndroidWebViewPlatform) {
|
|
params = AndroidWebViewControllerCreationParams
|
|
.fromPlatformWebViewControllerCreationParams(params);
|
|
}
|
|
|
|
final controller = WebViewController.fromPlatformCreationParams(
|
|
params,
|
|
onPermissionRequest: (request) {
|
|
unawaited(_handleWebViewPermissionRequest(request));
|
|
},
|
|
)
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..setBackgroundColor(Colors.white)
|
|
..setNavigationDelegate(
|
|
NavigationDelegate(
|
|
onProgress: (progress) {
|
|
if (mounted) {
|
|
setState(() => _progress = progress);
|
|
}
|
|
},
|
|
onPageStarted: (_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loadError = null;
|
|
_progress = 0;
|
|
});
|
|
}
|
|
},
|
|
onPageFinished: (_) {
|
|
if (mounted) {
|
|
setState(() => _progress = 100);
|
|
}
|
|
},
|
|
onWebResourceError: (error) {
|
|
if (error.isForMainFrame ?? true) {
|
|
if (mounted) {
|
|
setState(() => _loadError = error.description);
|
|
}
|
|
}
|
|
},
|
|
onUrlChange: (_) {
|
|
unawaited(_stopWebMedia());
|
|
},
|
|
onNavigationRequest: _handleNavigationRequest,
|
|
),
|
|
);
|
|
|
|
final platformController = controller.platform;
|
|
if (platformController is AndroidWebViewController) {
|
|
AndroidWebViewController.enableDebugging(false);
|
|
unawaited(platformController.setMediaPlaybackRequiresUserGesture(false));
|
|
unawaited(platformController.setGeolocationEnabled(true));
|
|
unawaited(
|
|
platformController.setGeolocationPermissionsPromptCallbacks(
|
|
onShowPrompt: (_) async {
|
|
final allowed =
|
|
await _requestPermission(Permission.locationWhenInUse);
|
|
return GeolocationPermissionsResponse(
|
|
allow: allowed,
|
|
retain: allowed,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return controller;
|
|
}
|
|
|
|
Future<void> _runJavaScriptSafely(String source) async {
|
|
try {
|
|
await _controller.runJavaScript(source);
|
|
} catch (_) {
|
|
// WebView can reject JavaScript while a page is still navigating.
|
|
}
|
|
}
|
|
|
|
Future<void> _stopWebMedia() {
|
|
return _runJavaScriptSafely(_stopWebMediaScript);
|
|
}
|
|
|
|
Future<NavigationDecision> _handleNavigationRequest(
|
|
NavigationRequest request,
|
|
) async {
|
|
unawaited(_stopWebMedia());
|
|
|
|
final uri = Uri.tryParse(request.url);
|
|
if (uri == null) {
|
|
return NavigationDecision.prevent;
|
|
}
|
|
|
|
const webSchemes = {'http', 'https', 'about', 'data'};
|
|
if (webSchemes.contains(uri.scheme)) {
|
|
return NavigationDecision.navigate;
|
|
}
|
|
|
|
try {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
} catch (_) {
|
|
// Ignore unsupported custom schemes so the WebView does not navigate to
|
|
// an error page.
|
|
}
|
|
return NavigationDecision.prevent;
|
|
}
|
|
|
|
Future<void> _handleWebViewPermissionRequest(
|
|
WebViewPermissionRequest request,
|
|
) async {
|
|
final permissions = <Permission>[];
|
|
if (request.types.contains(WebViewPermissionResourceType.camera)) {
|
|
permissions.add(Permission.camera);
|
|
}
|
|
if (request.types.contains(WebViewPermissionResourceType.microphone)) {
|
|
permissions.add(Permission.microphone);
|
|
}
|
|
|
|
final allowed = permissions.isEmpty ||
|
|
await Future.wait(permissions.map(_requestPermission))
|
|
.then((results) => results.every((allowed) => allowed));
|
|
|
|
if (allowed) {
|
|
await request.grant();
|
|
} else {
|
|
await request.deny();
|
|
}
|
|
}
|
|
|
|
Future<bool> _requestPermission(Permission permission) async {
|
|
final status = await permission.request();
|
|
return status.isGranted || status.isLimited;
|
|
}
|
|
|
|
Future<void> _handleBackNavigation() async {
|
|
await _stopWebMedia();
|
|
if (await _controller.canGoBack()) {
|
|
await _controller.goBack();
|
|
} else {
|
|
await SystemNavigator.pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (!didPop) {
|
|
unawaited(_handleBackNavigation());
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
body: SafeArea(
|
|
child: Stack(
|
|
children: [
|
|
WebViewWidget(controller: _controller),
|
|
if (_progress < 100)
|
|
LinearProgressIndicator(
|
|
value: _progress == 0 ? null : _progress / 100,
|
|
minHeight: 2,
|
|
),
|
|
if (_loadError != null)
|
|
_ErrorPanel(
|
|
message: _loadError!,
|
|
onRetry: () => _controller.loadRequest(Uri.parse(_homeUrl)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorPanel extends StatelessWidget {
|
|
const _ErrorPanel({required this.message, required this.onRetry});
|
|
|
|
final String message;
|
|
final VoidCallback onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: Colors.white,
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.wifi_off_rounded, size: 44),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'页面加载失败',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
onPressed: onRetry,
|
|
child: const Text('重新加载'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|