zyc 689fa8936b Integrate Volcengine realtime voice + Live2D mouth driving
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:39:23 +08:00

219 lines
6.9 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
}
}