rtc_prd/airhub_app/lib/services/music_generation_service.dart
2026-02-12 17:35:22 +08:00

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,
});
}