- 接入火山引擎豆包 Chat API 生成儿童故事(SSE 流式进度) - 新增 /api/stories 接口加载历史故事到书架 - 新增 /api/playlist 接口加载历史歌曲到唱片架 - 书架排序:预设故事在前,AI 生成在后 - AI 生成的故事显示"暂无封面"淡紫渐变占位 - 保存故事时传回真实标题+内容(不再用 mock) - 修复 Windows GBK 编码导致的中文乱码问题 - 新增 MusicGenerationService 单例管理音乐生成 - 音乐页心情卡片 UI 重做 + 歌词可读性优化 - 添加豆包 API 参考文档和故事创作 prompt Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
7.1 KiB
Dart
222 lines
7.1 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
/// Lightweight singleton that runs music generation in the background.
|
|
/// Survives page navigation — results are held until the music page picks them up.
|
|
class MusicGenerationService {
|
|
MusicGenerationService._();
|
|
static final MusicGenerationService instance = MusicGenerationService._();
|
|
|
|
static const String _kServerBase = 'http://localhost:3000';
|
|
|
|
// ── Current task state ──
|
|
bool _isGenerating = false;
|
|
double _progress = 0.0; // 0~100
|
|
String _statusMessage = '';
|
|
String _currentStage = '';
|
|
|
|
// ── Completed result (held until consumed) ──
|
|
MusicGenResult? _pendingResult;
|
|
|
|
// ── Pending error (held until consumed) ──
|
|
String? _pendingError;
|
|
|
|
// ── Callback for live UI updates (set by the music page when visible) ──
|
|
void Function(double progress, String stage, String message)? onProgress;
|
|
void Function(MusicGenResult result)? onComplete;
|
|
void Function(String error)? onError;
|
|
|
|
// ── Getters ──
|
|
bool get isGenerating => _isGenerating;
|
|
double get progress => _progress;
|
|
String get statusMessage => _statusMessage;
|
|
String get currentStage => _currentStage;
|
|
|
|
/// Check and consume any pending result (called when music page resumes).
|
|
MusicGenResult? consumePendingResult() {
|
|
final result = _pendingResult;
|
|
_pendingResult = null;
|
|
return result;
|
|
}
|
|
|
|
/// Check and consume any pending error (called when music page resumes).
|
|
String? consumePendingError() {
|
|
final error = _pendingError;
|
|
_pendingError = null;
|
|
return error;
|
|
}
|
|
|
|
/// Start a generation task. Safe to call even if page navigates away.
|
|
Future<void> generate({required String text, required String mood}) async {
|
|
if (_isGenerating) return; // Only one task at a time
|
|
|
|
_isGenerating = true;
|
|
_progress = 5;
|
|
_statusMessage = '正在连接 AI...';
|
|
_currentStage = 'connecting';
|
|
_pendingResult = null;
|
|
_pendingError = null;
|
|
onProgress?.call(_progress, _currentStage, _statusMessage);
|
|
|
|
try {
|
|
final request = http.Request(
|
|
'POST',
|
|
Uri.parse('$_kServerBase/api/create_music'),
|
|
);
|
|
request.headers['Content-Type'] = 'application/json';
|
|
request.body = jsonEncode({'text': text, 'mood': mood});
|
|
|
|
final client = http.Client();
|
|
final response = await client.send(request).timeout(
|
|
const Duration(seconds: 360),
|
|
);
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception('Server returned ${response.statusCode}');
|
|
}
|
|
|
|
// Parse SSE stream
|
|
String buffer = '';
|
|
String? newTitle;
|
|
String? newLyrics;
|
|
String? newFilePath;
|
|
|
|
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
|
buffer += chunk;
|
|
|
|
while (buffer.contains('\n\n')) {
|
|
final idx = buffer.indexOf('\n\n');
|
|
final line = buffer.substring(0, idx).trim();
|
|
buffer = buffer.substring(idx + 2);
|
|
|
|
if (!line.startsWith('data: ')) continue;
|
|
final jsonStr = line.substring(6);
|
|
|
|
try {
|
|
final event = jsonDecode(jsonStr) as Map<String, dynamic>;
|
|
final stage = event['stage'] as String? ?? '';
|
|
final message = event['message'] as String? ?? '';
|
|
|
|
switch (stage) {
|
|
case 'lyrics':
|
|
_updateProgress(10, stage, 'AI 正在创作词曲...');
|
|
break;
|
|
case 'lyrics_done':
|
|
case 'lyrics_fallback':
|
|
_updateProgress(25, stage, '词曲创作完成,准备生成音乐...');
|
|
break;
|
|
case 'music':
|
|
_updateProgress(30, stage, '正在生成音乐,请耐心等待...');
|
|
break;
|
|
case 'saving':
|
|
_updateProgress(90, stage, '音乐生成完成,正在保存...');
|
|
break;
|
|
case 'done':
|
|
newFilePath = event['file_path'] as String?;
|
|
final metadata = event['metadata'] as Map<String, dynamic>?;
|
|
newLyrics = metadata?['lyrics'] as String? ?? '';
|
|
newTitle = metadata?['song_title'] as String?;
|
|
if ((newTitle == null || newTitle.isEmpty) && newFilePath != null) {
|
|
final fname = newFilePath.split('/').last;
|
|
newTitle = fname.replaceAll(RegExp(r'_\d{10,}\.mp3$'), '');
|
|
}
|
|
_updateProgress(100, stage, '新歌出炉!');
|
|
break;
|
|
case 'error':
|
|
_isGenerating = false;
|
|
_progress = 0;
|
|
final errMsg = message.isNotEmpty ? message : '网络开小差了,再试一次~';
|
|
_statusMessage = errMsg;
|
|
if (onError != null) {
|
|
onError!(errMsg);
|
|
} else {
|
|
_pendingError = errMsg;
|
|
}
|
|
client.close();
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('SSE parse error: $e for line: $jsonStr');
|
|
}
|
|
}
|
|
}
|
|
|
|
client.close();
|
|
|
|
// Build result
|
|
_isGenerating = false;
|
|
_progress = 0;
|
|
|
|
if (newFilePath != null) {
|
|
final result = MusicGenResult(
|
|
title: newTitle ?? '新歌',
|
|
lyrics: newLyrics ?? '',
|
|
audioUrl: '$_kServerBase/$newFilePath',
|
|
);
|
|
|
|
// Always store as pending first; callback decides whether to consume
|
|
_pendingResult = result;
|
|
onComplete?.call(result);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Generate music error: $e');
|
|
_isGenerating = false;
|
|
_progress = 0;
|
|
const errMsg = '网络开小差了,再试一次~';
|
|
_statusMessage = errMsg;
|
|
if (onError != null) {
|
|
onError!(errMsg);
|
|
} else {
|
|
_pendingError = errMsg;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _updateProgress(double progress, String stage, String message) {
|
|
_progress = progress;
|
|
_currentStage = stage;
|
|
_statusMessage = message;
|
|
onProgress?.call(progress, stage, message);
|
|
}
|
|
|
|
/// Fetch saved songs from the server (scans Capybara music/ folder).
|
|
Future<List<MusicGenResult>> fetchPlaylist() async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('$_kServerBase/api/playlist'),
|
|
).timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode != 200) return [];
|
|
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
final list = data['playlist'] as List<dynamic>? ?? [];
|
|
|
|
return list.map((item) {
|
|
final m = item as Map<String, dynamic>;
|
|
return MusicGenResult(
|
|
title: m['title'] as String? ?? '',
|
|
lyrics: m['lyrics'] as String? ?? '',
|
|
audioUrl: '$_kServerBase/${m['audioUrl'] as String? ?? ''}',
|
|
);
|
|
}).toList();
|
|
} catch (e) {
|
|
debugPrint('Fetch playlist error: $e');
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of a completed music generation.
|
|
class MusicGenResult {
|
|
final String title;
|
|
final String lyrics;
|
|
final String audioUrl;
|
|
|
|
const MusicGenResult({
|
|
required this.title,
|
|
required this.lyrics,
|
|
required this.audioUrl,
|
|
});
|
|
}
|