219 lines
6.9 KiB
Dart
219 lines
6.9 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';
|
||
import 'voice_chat/voice_chat_controller.dart';
|
||
import 'voice_chat/voice_chat_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> with WidgetsBindingObserver {
|
||
AvatarBridge? _bridge;
|
||
bool _ready = false;
|
||
VoiceChatController? _voiceController;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
_voiceController?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
final controller = _voiceController;
|
||
if (controller == null) return;
|
||
if (state == AppLifecycleState.resumed) {
|
||
// 回到前台:重新建立会话
|
||
controller.startSession();
|
||
} else if (state == AppLifecycleState.paused) {
|
||
controller.stopSession();
|
||
}
|
||
}
|
||
|
||
void _ensureVoiceSessionStarted() {
|
||
if (_bridge == null) return;
|
||
_voiceController ??= VoiceChatController(bridge: _bridge!);
|
||
_voiceController!.startSession();
|
||
}
|
||
|
||
Widget _buildVoiceFab() {
|
||
// 第一次进入页面时立刻启动会话;同时监听 controller 让 FAB 颜色实时反映状态
|
||
_ensureVoiceSessionStarted();
|
||
return ListenableBuilder(
|
||
listenable: _voiceController!,
|
||
builder: (context, _) {
|
||
final c = _voiceController!;
|
||
final (label, bg) = c.connected
|
||
? ('语音对话 · 已连接', const Color(0xFF1E9F4D)) // 绿色
|
||
: c.connecting
|
||
? ('语音对话 · 连接中', const Color(0xFFD08F2C)) // 黄色
|
||
: ('语音对话 · 未连接', const Color(0xFF6B6B73)); // 灰色
|
||
return FloatingActionButton.extended(
|
||
heroTag: 'voice',
|
||
onPressed: () {
|
||
// 单击只展开抽屉看消息/日志;会话本身在后台一直跑
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => VoiceChatSheet(controller: c),
|
||
);
|
||
},
|
||
icon: Icon(c.connected ? Icons.graphic_eq : Icons.sync_problem),
|
||
label: Text(label),
|
||
backgroundColor: bg,
|
||
foregroundColor: Colors.white,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@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
|
||
? Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
_buildVoiceFab(),
|
||
const SizedBox(height: 12),
|
||
FloatingActionButton.extended(
|
||
heroTag: 'panel',
|
||
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,
|
||
);
|
||
}
|
||
}
|