import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; /// Singleton service that manages TTS generation in the background. /// Survives page navigation — when user leaves and comes back, /// generation continues and result is available. class TTSService extends ChangeNotifier { TTSService._(); static final TTSService instance = TTSService._(); static const String _kServerBase = 'http://localhost:3000'; // ── Current task state ── bool _isGenerating = false; double _progress = 0.0; // 0.0 ~ 1.0 String _statusMessage = ''; String? _currentStoryTitle; // Which story is being generated // ── Result ── String? _audioUrl; String? _completedStoryTitle; // Which story the audio belongs to bool _justCompleted = false; // Flash animation trigger // ── Error ── String? _error; // ── Getters ── bool get isGenerating => _isGenerating; double get progress => _progress; String get statusMessage => _statusMessage; String? get currentStoryTitle => _currentStoryTitle; String? get audioUrl => _audioUrl; String? get completedStoryTitle => _completedStoryTitle; bool get justCompleted => _justCompleted; String? get error => _error; /// Check if audio is ready for a specific story. bool hasAudioFor(String title) { return _completedStoryTitle == title && _audioUrl != null; } /// Check if currently generating for a specific story. bool isGeneratingFor(String title) { return _isGenerating && _currentStoryTitle == title; } /// Clear the "just completed" flag (after flash animation plays). void clearJustCompleted() { _justCompleted = false; notifyListeners(); } /// Set audio URL directly (e.g. from pre-check). void setExistingAudio(String title, String url) { _completedStoryTitle = title; _audioUrl = url; _justCompleted = false; notifyListeners(); } /// Check server for existing audio file. Future checkExistingAudio(String title) async { if (title.isEmpty) return; try { final resp = await http.get( Uri.parse( '$_kServerBase/api/tts_check?title=${Uri.encodeComponent(title)}', ), ); if (resp.statusCode == 200) { final data = jsonDecode(resp.body); if (data['exists'] == true && data['audio_url'] != null) { _completedStoryTitle = title; _audioUrl = '$_kServerBase/${data['audio_url']}'; notifyListeners(); } } } catch (_) {} } /// Start TTS generation. Safe to call even if page navigates away. Future generate({ required String title, required String content, }) async { if (_isGenerating) return; _isGenerating = true; _progress = 0.0; _statusMessage = '正在连接...'; _currentStoryTitle = title; _audioUrl = null; _completedStoryTitle = null; _justCompleted = false; _error = null; notifyListeners(); try { final client = http.Client(); final request = http.Request( 'POST', Uri.parse('$_kServerBase/api/create_tts'), ); request.headers['Content-Type'] = 'application/json'; request.body = jsonEncode({'title': title, 'content': content}); final streamed = await client.send(request); await for (final chunk in streamed.stream.transform(utf8.decoder)) { for (final line in chunk.split('\n')) { if (!line.startsWith('data: ')) continue; try { final data = jsonDecode(line.substring(6)); final stage = data['stage'] as String? ?? ''; final message = data['message'] as String? ?? ''; switch (stage) { case 'connecting': _updateProgress(0.10, '正在连接...'); break; case 'generating': _updateProgress(0.30, '语音生成中...'); break; case 'saving': _updateProgress(0.88, '正在保存...'); break; case 'done': if (data['audio_url'] != null) { _audioUrl = '$_kServerBase/${data['audio_url']}'; _completedStoryTitle = title; _justCompleted = true; _updateProgress(1.0, '生成完成'); } break; case 'error': throw Exception(message); default: // Progress slowly increases during generation if (_progress < 0.85) { _updateProgress(_progress + 0.02, message); } } } catch (e) { if (e is Exception && e.toString().contains('语音合成失败')) { rethrow; } } } } client.close(); _isGenerating = false; if (_audioUrl == null) { _error = '未获取到音频'; _statusMessage = '生成失败'; } notifyListeners(); } catch (e) { debugPrint('TTS generation error: $e'); _isGenerating = false; _progress = 0.0; _error = e.toString(); _statusMessage = '生成失败'; _justCompleted = false; notifyListeners(); } } void _updateProgress(double progress, String message) { _progress = progress.clamp(0.0, 1.0); _statusMessage = message; notifyListeners(); } /// Reset all state (e.g. when switching stories). void reset() { if (_isGenerating) return; // Don't reset during generation _progress = 0.0; _statusMessage = ''; _currentStoryTitle = null; _audioUrl = null; _completedStoryTitle = null; _justCompleted = false; _error = null; notifyListeners(); } }