live_screen.dart 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:permission_handler/permission_handler.dart';
  4. import 'package:gemini_live_app/widgets/visualizer.dart';
  5. import 'package:gemini_live_app/services/audio_service.dart';
  6. import 'package:gemini_live_app/infrastructure/audio_service_impl.dart';
  7. import 'package:gemini_live_app/services/llm_service.dart';
  8. import 'package:gemini_live_app/infrastructure/gemini_llm_service_impl.dart';
  9. /// The screen where the live conversation with the AI takes place.
  10. ///
  11. /// This screen handles the initialization of audio and LLM services,
  12. /// manages the conversation state, and displays the visualizer.
  13. class LiveScreen extends StatefulWidget {
  14. const LiveScreen({super.key});
  15. @override
  16. State<LiveScreen> createState() => _LiveScreenState();
  17. }
  18. class _LiveScreenState extends State<LiveScreen> {
  19. bool _isInitializing = false;
  20. bool _isConnectionActive = false;
  21. bool _isLLMSpeaking = false;
  22. bool _isLive = false;
  23. final AudioService _audioService = AudioServiceImpl();
  24. final LLMService _llmService = GeminiLLMServiceImpl();
  25. @override
  26. void initState() {
  27. super.initState();
  28. _initializeServices();
  29. }
  30. /// Initializes the audio and LLM services.
  31. ///
  32. /// This method checks for microphone permission, initializes the services,
  33. /// and starts the audio streaming.
  34. Future<void> _initializeServices() async {
  35. if (_isInitializing) return;
  36. setState(() => _isInitializing = true);
  37. if (!await Permission.microphone.isGranted) {
  38. _showError('Microphone permission is required for live conversation.');
  39. setState(() => _isInitializing = false);
  40. return;
  41. }
  42. _llmService.connectionStatus.addListener(_onLLMConnectionChanged);
  43. _audioService.isLLMSpeaking.addListener(_onLLMSpeakingChanged);
  44. try {
  45. await _audioService.initialize();
  46. await _llmService.connect();
  47. _audioService.playIncomingAudio(_llmService.onAudioReceived);
  48. await _audioService.startRecording();
  49. _llmService.sendAudioStream(_audioService.outgoingAudioStream);
  50. setState(() {
  51. _isInitializing = false;
  52. _isLive = true;
  53. });
  54. } catch (e) {
  55. _showError('Failed to initialize: $e');
  56. setState(() {
  57. _isInitializing = false;
  58. _isLive = false;
  59. });
  60. }
  61. }
  62. /// Called when the LLM speaking status changes.
  63. void _onLLMSpeakingChanged() {
  64. if (mounted) {
  65. setState(() {
  66. _isLLMSpeaking = _audioService.isLLMSpeaking.value;
  67. });
  68. }
  69. }
  70. /// Called when the LLM connection status changes.
  71. void _onLLMConnectionChanged() {
  72. final newStatus = _llmService.connectionStatus.value;
  73. if (mounted) {
  74. setState(() {
  75. _isConnectionActive = newStatus;
  76. if (!newStatus && _isLive) {
  77. _endConversation();
  78. }
  79. });
  80. }
  81. }
  82. /// Ends the conversation and disposes of the services.
  83. void _endConversation() async {
  84. if (!_isLive && !_isInitializing) {
  85. Navigator.of(context).pop();
  86. return;
  87. }
  88. setState(() => _isLive = false);
  89. await _audioService.stopRecording();
  90. await _audioService.stopPlayback();
  91. if (mounted) {
  92. Navigator.of(context).pop();
  93. }
  94. }
  95. @override
  96. void dispose() {
  97. _audioService.isLLMSpeaking.removeListener(_onLLMSpeakingChanged);
  98. _llmService.connectionStatus.removeListener(_onLLMConnectionChanged);
  99. _llmService.dispose();
  100. _audioService.dispose();
  101. super.dispose();
  102. }
  103. @override
  104. Widget build(BuildContext context) {
  105. return Scaffold(
  106. body: SafeArea(
  107. child: Padding(
  108. padding: const EdgeInsets.symmetric(vertical: 24.0),
  109. child: Column(
  110. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  111. children: [
  112. const Padding(
  113. padding: EdgeInsets.symmetric(horizontal: 16.0),
  114. child: Row(
  115. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  116. children: [
  117. Text(
  118. 'Gemini Live',
  119. style: TextStyle(
  120. fontSize: 24,
  121. fontWeight: FontWeight.w500,
  122. color: Colors.white,
  123. ),
  124. ),
  125. ],
  126. ),
  127. ),
  128. Expanded(
  129. child: Column(
  130. mainAxisAlignment: MainAxisAlignment.center,
  131. children: [
  132. Visualizer(isTalking: _isLLMSpeaking),
  133. const SizedBox(height: 40),
  134. _buildStatusText(),
  135. ],
  136. ),
  137. ),
  138. _buildEndCallButton(),
  139. ],
  140. ),
  141. ),
  142. ),
  143. );
  144. }
  145. /// Builds the status text widget.
  146. Widget _buildStatusText() {
  147. String text;
  148. if (_isInitializing) {
  149. text = 'Connecting...';
  150. } else if (!_isConnectionActive) {
  151. text = 'Disconnected. Check API key or permissions.';
  152. } else if (_isLive) {
  153. text = _isLLMSpeaking
  154. ? 'LLM is speaking...'
  155. : 'Live Conversation...';
  156. } else {
  157. text = 'Conversation Ended.';
  158. }
  159. return Text(text, style: TextStyle(fontSize: 16, color: Colors.grey[400]));
  160. }
  161. /// Builds the end call button.
  162. Widget _buildEndCallButton() {
  163. final bool isEnabled = !_isInitializing && _isConnectionActive;
  164. return Padding(
  165. padding: const EdgeInsets.only(bottom: 24.0),
  166. child: FloatingActionButton(
  167. onPressed: isEnabled ? _endConversation : null,
  168. backgroundColor: isEnabled ? Colors.red.shade700 : Colors.grey.shade800,
  169. child: Icon(
  170. Icons.call_end,
  171. color: isEnabled ? Colors.white : Colors.grey.shade600,
  172. size: 36,
  173. ),
  174. ),
  175. );
  176. }
  177. /// Shows an error dialog.
  178. void _showError(String message) {
  179. if (!mounted) return;
  180. showDialog(
  181. context: context,
  182. builder: (context) => AlertDialog(
  183. title: const Text('Error'),
  184. content: Text(message),
  185. actions: [
  186. TextButton(
  187. onPressed: () {
  188. Navigator.of(context).pop();
  189. if (!_isConnectionActive && !_isInitializing) {
  190. Navigator.of(context).pop();
  191. }
  192. },
  193. child: const Text('OK'),
  194. ),
  195. ],
  196. ),
  197. );
  198. }
  199. }