包含三个子项目: - avatar-h5-renderer: Live2D Cubism 4 H5 渲染器 (Vite + TS) - avatar_flutter_app: Flutter 容器 App (打包 H5 进 WebView) - gif-export: puppeteer 导出 32 个动作的透明 GIF (供 ESP32 圆屏播放) 模型资源: Haru, Natori (含贴图、moc3、motions, expressions) 设计文档: AI驱动虚拟形象渲染方案_v5.1.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
4.4 KiB
Dart
144 lines
4.4 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||
|
||
import 'avatar_bridge.dart';
|
||
import 'control_panel_sheet.dart';
|
||
|
||
int _serverPort = 8080;
|
||
|
||
/// 探测端口是否可用 —— InAppLocalhostServer.start() 在端口被占时会死等不抛异常,
|
||
/// 所以必须先用 ServerSocket 试探一下能不能 bind。
|
||
Future<bool> _isPortFree(int port) async {
|
||
try {
|
||
final s = await ServerSocket.bind('127.0.0.1', port);
|
||
await s.close();
|
||
return true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<void> main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
// 同机多端(macOS + iOS sim 共享 localhost)端口可能冲突,循环找一个空闲端口
|
||
for (var port = 8080; port < 8090; port++) {
|
||
if (await _isPortFree(port)) {
|
||
await InAppLocalhostServer(
|
||
documentRoot: 'assets/web',
|
||
port: port,
|
||
).start();
|
||
_serverPort = port;
|
||
debugPrint('[server] listening on localhost:$port');
|
||
break;
|
||
} else {
|
||
debugPrint('[server] port $port busy, trying next');
|
||
}
|
||
}
|
||
runApp(const AvatarApp());
|
||
}
|
||
|
||
class AvatarApp extends StatelessWidget {
|
||
const AvatarApp({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MaterialApp(
|
||
title: 'Avatar PoC',
|
||
debugShowCheckedModeBanner: false,
|
||
theme: ThemeData(
|
||
useMaterial3: true,
|
||
brightness: Brightness.dark,
|
||
scaffoldBackgroundColor: const Color(0xFF1A1A1F),
|
||
colorScheme: const ColorScheme.dark(
|
||
primary: Color(0xFF4488DD),
|
||
surface: Color(0xFF222228),
|
||
),
|
||
),
|
||
home: const AvatarHomePage(),
|
||
);
|
||
}
|
||
}
|
||
|
||
class AvatarHomePage extends StatefulWidget {
|
||
const AvatarHomePage({super.key});
|
||
|
||
@override
|
||
State<AvatarHomePage> createState() => _AvatarHomePageState();
|
||
}
|
||
|
||
class _AvatarHomePageState extends State<AvatarHomePage> {
|
||
AvatarBridge? _bridge;
|
||
bool _ready = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: SafeArea(
|
||
child: Stack(
|
||
children: [
|
||
InAppWebView(
|
||
initialUrlRequest: URLRequest(
|
||
url: WebUri('http://localhost:$_serverPort/index.html'),
|
||
),
|
||
initialSettings: InAppWebViewSettings(
|
||
javaScriptEnabled: true,
|
||
transparentBackground: false,
|
||
allowsInlineMediaPlayback: true,
|
||
mediaPlaybackRequiresUserGesture: false,
|
||
// macOS / iOS:允许 localhost 加载
|
||
allowsBackForwardNavigationGestures: false,
|
||
),
|
||
onWebViewCreated: (controller) {
|
||
_bridge = AvatarBridge(controller);
|
||
},
|
||
onLoadStop: (controller, _) async {
|
||
// 隐藏 H5 内置调试面板(Flutter 接管控制权)
|
||
await controller.evaluateJavascript(source: '''
|
||
const p = document.getElementById('debug-panel');
|
||
if (p) p.style.display = 'none';
|
||
''');
|
||
// 等 avatar 全局对象注册完成
|
||
await _bridge?.waitReady();
|
||
if (mounted) setState(() => _ready = true);
|
||
},
|
||
onConsoleMessage: (_, msg) {
|
||
debugPrint('[webview] ${msg.message}');
|
||
},
|
||
),
|
||
if (!_ready)
|
||
const Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircularProgressIndicator(),
|
||
SizedBox(height: 12),
|
||
Text('Loading Live2D...'),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
floatingActionButton: _ready
|
||
? FloatingActionButton.extended(
|
||
onPressed: () {
|
||
if (_bridge == null) return;
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => ControlPanelSheet(bridge: _bridge!),
|
||
);
|
||
},
|
||
icon: const Icon(Icons.tune),
|
||
label: const Text('控制面板'),
|
||
backgroundColor: const Color(0xFF2766B6),
|
||
foregroundColor: Colors.white,
|
||
)
|
||
: null,
|
||
);
|
||
}
|
||
}
|