- 添加 video_player 依赖,实现 OSS 视频播放 - 故事有 has_video 时自动切换到绘本 Tab 并初始化播放器 - 修复播放按钮尺寸及 GestureDetector 事件穿透问题 - TTSService 新增 errorTitle 字段,避免跨故事错误状态污染 - 修复 device entity 相关代码 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
6.7 KiB
Dart
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();
|
|
}
|
|
}
|