260 lines
8.4 KiB
Dart
260 lines
8.4 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';
|
|
|
|
/// 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._();
|
|
|
|
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~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 token = await _getToken();
|
|
final request = http.Request(
|
|
'POST',
|
|
Uri.parse('${ApiConfig.fullBaseUrl}/music/generate/'),
|
|
);
|
|
request.headers['Content-Type'] = 'application/json';
|
|
if (token != null) {
|
|
request.headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
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) {
|
|
// Backend may return JSON error for non-SSE responses (e.g. points not enough)
|
|
final body = await response.stream.bytesToString();
|
|
String errMsg = '服务器返回错误 (${response.statusCode})';
|
|
try {
|
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
errMsg = json['message'] as String? ?? errMsg;
|
|
} catch (_) {}
|
|
throw Exception(errMsg);
|
|
}
|
|
|
|
// Parse SSE stream
|
|
String buffer = '';
|
|
String? newTitle;
|
|
String? newLyrics;
|
|
String? newAudioUrl;
|
|
String? newCoverUrl;
|
|
int? newTrackId;
|
|
|
|
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':
|
|
newTrackId = event['track_id'] as int?;
|
|
newAudioUrl = event['audio_url'] as String?;
|
|
newCoverUrl = event['cover_url'] 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) {
|
|
newTitle = '咔咔新歌';
|
|
}
|
|
_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 (newAudioUrl != null) {
|
|
final result = MusicGenResult(
|
|
id: newTrackId,
|
|
title: newTitle ?? '新歌',
|
|
lyrics: newLyrics ?? '',
|
|
audioUrl: newAudioUrl,
|
|
coverUrl: newCoverUrl ?? '',
|
|
);
|
|
|
|
// 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;
|
|
final errMsg = e.toString().replaceFirst('Exception: ', '');
|
|
_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 playlist from backend API.
|
|
Future<List<MusicGenResult>> fetchPlaylist() async {
|
|
try {
|
|
final token = await _getToken();
|
|
final response = await http.get(
|
|
Uri.parse('${ApiConfig.fullBaseUrl}/music/playlist/'),
|
|
headers: {
|
|
if (token != null) 'Authorization': 'Bearer $token',
|
|
},
|
|
).timeout(const Duration(seconds: 10));
|
|
|
|
if (response.statusCode != 200) return [];
|
|
|
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
|
final data = body['data'] as Map<String, dynamic>? ?? {};
|
|
final list = data['playlist'] as List<dynamic>? ?? [];
|
|
|
|
return list.map((item) {
|
|
final m = item as Map<String, dynamic>;
|
|
return MusicGenResult(
|
|
id: m['id'] as int?,
|
|
title: m['title'] as String? ?? '',
|
|
lyrics: m['lyrics'] as String? ?? '',
|
|
audioUrl: m['audio_url'] as String? ?? '',
|
|
coverUrl: m['cover_url'] as String? ?? '',
|
|
isFavorite: m['is_favorite'] as bool? ?? false,
|
|
isDefault: m['is_default'] as bool? ?? false,
|
|
);
|
|
}).toList();
|
|
} catch (e) {
|
|
debugPrint('Fetch playlist error: $e');
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of a completed music generation or a playlist track.
|
|
class MusicGenResult {
|
|
final int? id;
|
|
final String title;
|
|
final String lyrics;
|
|
final String audioUrl;
|
|
final String coverUrl;
|
|
final bool isFavorite;
|
|
final bool isDefault;
|
|
|
|
const MusicGenResult({
|
|
this.id,
|
|
required this.title,
|
|
required this.lyrics,
|
|
required this.audioUrl,
|
|
this.coverUrl = '',
|
|
this.isFavorite = false,
|
|
this.isDefault = false,
|
|
});
|
|
}
|