import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../core/network/api_config.dart'; /// 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._(); Future _getToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('access_token'); } // ── 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; String? _errorTitle; // Which story the error belongs to // ── 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; String? get errorTitle => _errorTitle; /// 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 by story ID. Future checkExistingAudio(String title, {int? storyId}) async { if (title.isEmpty || storyId == null) return; try { final token = await _getToken(); final resp = await http.get( Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'), headers: { if (token != null) 'Authorization': 'Bearer $token', }, ); if (resp.statusCode == 200) { final body = jsonDecode(resp.body); final data = body['data']; if (data != null && data['exists'] == true && data['audio_url'] != null) { _completedStoryTitle = title; _audioUrl = data['audio_url'] as String; notifyListeners(); } } } catch (_) {} } /// Start TTS generation. Safe to call even if page navigates away. /// [storyId] is required to call the Django backend TTS endpoint. Future generate({ required String title, required String content, required int storyId, }) async { if (_isGenerating) return; _isGenerating = true; _progress = 0.0; _statusMessage = '正在连接...'; _currentStoryTitle = title; _audioUrl = null; _completedStoryTitle = null; _justCompleted = false; _error = null; notifyListeners(); try { final token = await _getToken(); final client = http.Client(); final request = http.Request( 'POST', Uri.parse('${ApiConfig.fullBaseUrl}/stories/$storyId/tts/'), ); request.headers['Content-Type'] = 'application/json'; if (token != null) { request.headers['Authorization'] = 'Bearer $token'; } final streamed = await client.send(request); String buffer = ''; await for (final chunk in streamed.stream.transform(utf8.decoder)) { buffer += chunk; while (buffer.contains('\n\n')) { final idx = buffer.indexOf('\n\n'); final block = buffer.substring(0, idx).trim(); buffer = buffer.substring(idx + 2); String? jsonStr; for (final line in block.split('\n')) { if (line.startsWith('data: ')) { jsonStr = line.substring(6); break; } } if (jsonStr == null) continue; try { final data = jsonDecode(jsonStr); 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 = data['audio_url'] as String; _completedStoryTitle = title; _justCompleted = true; _updateProgress(1.0, '生成完成'); } break; case 'error': throw Exception(message); default: 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 = '未获取到音频'; _errorTitle = title; _statusMessage = '生成失败'; } notifyListeners(); } catch (e) { debugPrint('TTS generation error: $e'); _isGenerating = false; _progress = 0.0; _error = e.toString(); _errorTitle = title; _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; _errorTitle = null; notifyListeners(); } }