rtc_prd/airhub_app/lib/services/tts_service.dart
repair-agent d741fd4f5c feat: 实现故事绘本视频播放及修复TTS状态管理
- 添加 video_player 依赖,实现 OSS 视频播放
- 故事有 has_video 时自动切换到绘本 Tab 并初始化播放器
- 修复播放按钮尺寸及 GestureDetector 事件穿透问题
- TTSService 新增 errorTitle 字段,避免跨故事错误状态污染
- 修复 device entity 相关代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:16:58 +08:00

223 lines
6.7 KiB
Dart

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<String?> _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<void> 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<void> 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();
}
}