fix music

This commit is contained in:
zyc 2026-02-12 17:35:22 +08:00
parent 86d1b77fa7
commit 6e0c8e943f
3 changed files with 158 additions and 51 deletions

View File

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import '../services/music_generation_service.dart'; import '../services/music_generation_service.dart';
@ -18,22 +19,24 @@ import '../theme/app_colors.dart' as appclr;
/// Playlist track data /// Playlist track data
class _Track { class _Track {
final int id; final int id;
final int? serverId; // Backend track ID (for delete/favorite)
final String title; final String title;
final String lyrics; final String lyrics;
String audioAsset; String audioAsset;
final bool isRemote; // true = URL from server, false = local asset final bool isRemote; // true = URL from server, false = local asset
final bool isDefault; // Backend default track (undeletable)
_Track({ _Track({
required this.id, required this.id,
this.serverId,
required this.title, required this.title,
required this.lyrics, required this.lyrics,
required this.audioAsset, required this.audioAsset,
this.isRemote = false, this.isRemote = false,
this.isDefault = false,
}); });
} }
/// Server base URL change this when deploying
class MusicCreationPage extends StatefulWidget { class MusicCreationPage extends StatefulWidget {
/// Whether this page is embedded as a tab (hides back button) /// Whether this page is embedded as a tab (hides back button)
final bool isTab; final bool isTab;
@ -214,6 +217,9 @@ class _MusicCreationPageState extends State<MusicCreationPage>
_currentTime = _formatDuration(position); _currentTime = _formatDuration(position);
}); });
} }
}, onError: (e) {
// Silently ignore PlatformException(abort) expected when switching tracks
debugPrint('positionStream error (ignored): $e');
}); });
// Listen to duration update total time label // Listen to duration update total time label
@ -222,6 +228,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
setState(() { setState(() {
_totalTime = _formatDuration(duration); _totalTime = _formatDuration(duration);
}); });
}, onError: (e) {
debugPrint('durationStream error (ignored): $e');
}); });
// Listen to player state detect track completion // Listen to player state detect track completion
@ -230,6 +238,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
if (state.processingState == ProcessingState.completed) { if (state.processingState == ProcessingState.completed) {
_onTrackComplete(); _onTrackComplete();
} }
}, onError: (e) {
debugPrint('playerStateStream error (ignored): $e');
}); });
// Pre-load the first track (don't auto-play) // Pre-load the first track (don't auto-play)
@ -269,40 +279,49 @@ class _MusicCreationPageState extends State<MusicCreationPage>
}); });
} }
// Load historical songs from server // Load playlist from backend API
_loadHistoricalSongs(); _loadPlaylist();
} }
// Load historical songs from server into playlist // Load playlist from backend API replaces entire playlist
Future<void> _loadHistoricalSongs() async { Future<void> _loadPlaylist() async {
final songs = await _genService.fetchPlaylist(); final songs = await _genService.fetchPlaylist();
if (!mounted || songs.isEmpty) return; if (!mounted || songs.isEmpty) return;
// Collect titles already in playlist to avoid duplicates // Remember the currently playing track title to restore position
final existingTitles = _playlist.map((t) => t.title).toSet(); final currentTitle = _playlist.isNotEmpty
? _playlist[_currentTrackIndex].title
: null;
final newTracks = <_Track>[]; final backendTracks = songs.map((song) => _Track(
for (final song in songs) { id: song.id ?? DateTime.now().millisecondsSinceEpoch,
if (existingTitles.contains(song.title)) continue; serverId: song.id,
newTracks.add(_Track(
id: DateTime.now().millisecondsSinceEpoch + newTracks.length,
title: song.title, title: song.title,
lyrics: song.lyrics, lyrics: song.lyrics,
audioAsset: song.audioUrl, audioAsset: song.audioUrl,
isRemote: true, isRemote: true,
)); isDefault: song.isDefault,
)).toList();
// Find the index of the previously playing track in the new list
int newIndex = 0;
if (currentTitle != null) {
final found = backendTracks.indexWhere((t) => t.title == currentTitle);
if (found >= 0) newIndex = found;
} }
if (newTracks.isEmpty) return;
setState(() { setState(() {
// Insert server songs at the beginning (before hardcoded tracks) _playlist.clear();
_playlist.insertAll(0, newTracks); _playlist.addAll(backendTracks);
// Shift current track index so it still points to the same track _currentTrackIndex = newIndex;
_currentTrackIndex += newTracks.length;
}); });
debugPrint('Loaded ${newTracks.length} historical songs from server'); // Re-load the current track if not playing
if (!_isPlaying) {
_loadTrack(_currentTrackIndex);
}
debugPrint('Loaded ${backendTracks.length} tracks from backend');
} }
// Duration formatter // Duration formatter
@ -323,6 +342,16 @@ class _MusicCreationPageState extends State<MusicCreationPage>
// Local preset track load from assets // Local preset track load from assets
await _audioPlayer.setAsset(track.audioAsset); await _audioPlayer.setAsset(track.audioAsset);
} }
} on PlatformException catch (e) {
// PlatformException(abort) is expected when switching tracks quickly
if (e.code == 'abort') {
debugPrint('Track load interrupted (expected): $e');
return;
}
debugPrint('Error loading track: $e');
if (mounted) {
_showSpeech('音频加载失败,请重试');
}
} catch (e) { } catch (e) {
debugPrint('Error loading track: $e'); debugPrint('Error loading track: $e');
if (mounted) { if (mounted) {
@ -501,13 +530,23 @@ class _MusicCreationPageState extends State<MusicCreationPage>
return; return;
} }
setState(() => _selectedMoodIndex = index);
final mood = _moods[index]; final mood = _moods[index];
_generateMusic( final text = (mood['prompt'] as String).isNotEmpty
text: (mood['prompt'] as String).isNotEmpty
? mood['prompt'] as String ? mood['prompt'] as String
: '咔咔今天想来点惊喜', : '咔咔今天想来点惊喜';
mood: mood['mood'] as String, final moodValue = mood['mood'] as String;
showGlassDialog(
context: context,
title: '创作新歌',
description: '确认消耗 100 积分生成音乐?',
cancelText: '再想想',
confirmText: '开始创作',
onConfirm: () {
Navigator.pop(context);
setState(() => _selectedMoodIndex = index);
_generateMusic(text: text, mood: moodValue);
},
); );
} }
@ -572,7 +611,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
}); });
final newTrack = _Track( final newTrack = _Track(
id: DateTime.now().millisecondsSinceEpoch, id: result.id ?? DateTime.now().millisecondsSinceEpoch,
serverId: result.id,
title: result.title, title: result.title,
lyrics: result.lyrics, lyrics: result.lyrics,
audioAsset: result.audioUrl, audioAsset: result.audioUrl,
@ -581,6 +621,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
setState(() { setState(() {
_playlist.insert(0, newTrack); _playlist.insert(0, newTrack);
// Shift current track index since we inserted at 0
_currentTrackIndex++;
}); });
// Always show dialog, never auto-play // Always show dialog, never auto-play
@ -597,7 +639,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
}); });
final newTrack = _Track( final newTrack = _Track(
id: DateTime.now().millisecondsSinceEpoch, id: result.id ?? DateTime.now().millisecondsSinceEpoch,
serverId: result.id,
title: result.title, title: result.title,
lyrics: result.lyrics, lyrics: result.lyrics,
audioAsset: result.audioUrl, audioAsset: result.audioUrl,
@ -606,6 +649,8 @@ class _MusicCreationPageState extends State<MusicCreationPage>
setState(() { setState(() {
_playlist.insert(0, newTrack); _playlist.insert(0, newTrack);
// Shift current track index since we inserted at 0
_currentTrackIndex++;
}); });
if (_isPlaying) { if (_isPlaying) {
@ -1495,9 +1540,19 @@ class _MusicCreationPageState extends State<MusicCreationPage>
controller: controller, controller: controller,
onSubmit: (text) { onSubmit: (text) {
Navigator.pop(ctx); Navigator.pop(ctx);
showGlassDialog(
context: context,
title: '创作新歌',
description: '确认消耗 100 积分生成音乐?',
cancelText: '再想想',
confirmText: '开始创作',
onConfirm: () {
Navigator.pop(context);
setState(() => _selectedMoodIndex = 5); setState(() => _selectedMoodIndex = 5);
_generateMusic(text: text, mood: 'custom'); _generateMusic(text: text, mood: 'custom');
}, },
);
},
), ),
); );
} }

View File

@ -2,6 +2,7 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/gradient_button.dart'; import '../widgets/gradient_button.dart';
@ -87,18 +88,24 @@ class _StoryDetailPageState extends State<StoryDetailPage>
_audioPosition = Duration.zero; _audioPosition = Duration.zero;
}); });
} }
}, onError: (e) {
debugPrint('playerStateStream error (ignored): $e');
}); });
// Listen to playback position for ring progress // Listen to playback position for ring progress
_positionSub = _audioPlayer.positionStream.listen((pos) { _positionSub = _audioPlayer.positionStream.listen((pos) {
if (!mounted) return; if (!mounted) return;
setState(() => _audioPosition = pos); setState(() => _audioPosition = pos);
}, onError: (e) {
debugPrint('positionStream error (ignored): $e');
}); });
// Listen to duration changes // Listen to duration changes
_audioPlayer.durationStream.listen((dur) { _audioPlayer.durationStream.listen((dur) {
if (!mounted || dur == null) return; if (!mounted || dur == null) return;
setState(() => _audioDuration = dur); setState(() => _audioDuration = dur);
}, onError: (e) {
debugPrint('durationStream error (ignored): $e');
}); });
// Check if audio already exists // Check if audio already exists
@ -250,6 +257,13 @@ class _StoryDetailPageState extends State<StoryDetailPage>
if (mounted) { if (mounted) {
setState(() => _isPlaying = true); setState(() => _isPlaying = true);
} }
} on PlatformException catch (e) {
// PlatformException(abort) is expected when loading is interrupted
if (e.code == 'abort') {
debugPrint('Audio load interrupted (expected): $e');
return;
}
debugPrint('Audio play error: $e');
} catch (e) { } catch (e) {
debugPrint('Audio play error: $e'); debugPrint('Audio play error: $e');
} }

View File

@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; 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. /// Lightweight singleton that runs music generation in the background.
/// Survives page navigation results are held until the music page picks them up. /// Survives page navigation results are held until the music page picks them up.
@ -8,7 +10,10 @@ class MusicGenerationService {
MusicGenerationService._(); MusicGenerationService._();
static final MusicGenerationService instance = MusicGenerationService._(); static final MusicGenerationService instance = MusicGenerationService._();
static const String _kServerBase = 'http://localhost:3000'; Future<String?> _getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('access_token');
}
// Current task state // Current task state
bool _isGenerating = false; bool _isGenerating = false;
@ -60,11 +65,15 @@ class MusicGenerationService {
onProgress?.call(_progress, _currentStage, _statusMessage); onProgress?.call(_progress, _currentStage, _statusMessage);
try { try {
final token = await _getToken();
final request = http.Request( final request = http.Request(
'POST', 'POST',
Uri.parse('$_kServerBase/api/create_music'), Uri.parse('${ApiConfig.fullBaseUrl}/music/generate/'),
); );
request.headers['Content-Type'] = 'application/json'; request.headers['Content-Type'] = 'application/json';
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.body = jsonEncode({'text': text, 'mood': mood}); request.body = jsonEncode({'text': text, 'mood': mood});
final client = http.Client(); final client = http.Client();
@ -73,14 +82,23 @@ class MusicGenerationService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}'); // 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 // Parse SSE stream
String buffer = ''; String buffer = '';
String? newTitle; String? newTitle;
String? newLyrics; String? newLyrics;
String? newFilePath; String? newAudioUrl;
String? newCoverUrl;
int? newTrackId;
await for (final chunk in response.stream.transform(utf8.decoder)) { await for (final chunk in response.stream.transform(utf8.decoder)) {
buffer += chunk; buffer += chunk;
@ -113,13 +131,14 @@ class MusicGenerationService {
_updateProgress(90, stage, '音乐生成完成,正在保存...'); _updateProgress(90, stage, '音乐生成完成,正在保存...');
break; break;
case 'done': case 'done':
newFilePath = event['file_path'] as String?; 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>?; final metadata = event['metadata'] as Map<String, dynamic>?;
newLyrics = metadata?['lyrics'] as String? ?? ''; newLyrics = metadata?['lyrics'] as String? ?? '';
newTitle = metadata?['song_title'] as String?; newTitle = metadata?['song_title'] as String?;
if ((newTitle == null || newTitle.isEmpty) && newFilePath != null) { if (newTitle == null || newTitle.isEmpty) {
final fname = newFilePath.split('/').last; newTitle = '咔咔新歌';
newTitle = fname.replaceAll(RegExp(r'_\d{10,}\.mp3$'), '');
} }
_updateProgress(100, stage, '新歌出炉!'); _updateProgress(100, stage, '新歌出炉!');
break; break;
@ -148,11 +167,13 @@ class MusicGenerationService {
_isGenerating = false; _isGenerating = false;
_progress = 0; _progress = 0;
if (newFilePath != null) { if (newAudioUrl != null) {
final result = MusicGenResult( final result = MusicGenResult(
id: newTrackId,
title: newTitle ?? '新歌', title: newTitle ?? '新歌',
lyrics: newLyrics ?? '', lyrics: newLyrics ?? '',
audioUrl: '$_kServerBase/$newFilePath', audioUrl: newAudioUrl,
coverUrl: newCoverUrl ?? '',
); );
// Always store as pending first; callback decides whether to consume // Always store as pending first; callback decides whether to consume
@ -163,7 +184,7 @@ class MusicGenerationService {
debugPrint('Generate music error: $e'); debugPrint('Generate music error: $e');
_isGenerating = false; _isGenerating = false;
_progress = 0; _progress = 0;
const errMsg = '网络开小差了,再试一次~'; final errMsg = e.toString().replaceFirst('Exception: ', '');
_statusMessage = errMsg; _statusMessage = errMsg;
if (onError != null) { if (onError != null) {
onError!(errMsg); onError!(errMsg);
@ -180,24 +201,33 @@ class MusicGenerationService {
onProgress?.call(progress, stage, message); onProgress?.call(progress, stage, message);
} }
/// Fetch saved songs from the server (scans Capybara music/ folder). /// Fetch playlist from backend API.
Future<List<MusicGenResult>> fetchPlaylist() async { Future<List<MusicGenResult>> fetchPlaylist() async {
try { try {
final token = await _getToken();
final response = await http.get( final response = await http.get(
Uri.parse('$_kServerBase/api/playlist'), Uri.parse('${ApiConfig.fullBaseUrl}/music/playlist/'),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
).timeout(const Duration(seconds: 10)); ).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) return []; if (response.statusCode != 200) return [];
final data = jsonDecode(response.body) as Map<String, dynamic>; final body = jsonDecode(response.body) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? {};
final list = data['playlist'] as List<dynamic>? ?? []; final list = data['playlist'] as List<dynamic>? ?? [];
return list.map((item) { return list.map((item) {
final m = item as Map<String, dynamic>; final m = item as Map<String, dynamic>;
return MusicGenResult( return MusicGenResult(
id: m['id'] as int?,
title: m['title'] as String? ?? '', title: m['title'] as String? ?? '',
lyrics: m['lyrics'] as String? ?? '', lyrics: m['lyrics'] as String? ?? '',
audioUrl: '$_kServerBase/${m['audioUrl'] 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(); }).toList();
} catch (e) { } catch (e) {
@ -207,15 +237,23 @@ class MusicGenerationService {
} }
} }
/// Result of a completed music generation. /// Result of a completed music generation or a playlist track.
class MusicGenResult { class MusicGenResult {
final int? id;
final String title; final String title;
final String lyrics; final String lyrics;
final String audioUrl; final String audioUrl;
final String coverUrl;
final bool isFavorite;
final bool isDefault;
const MusicGenResult({ const MusicGenResult({
this.id,
required this.title, required this.title,
required this.lyrics, required this.lyrics,
required this.audioUrl, required this.audioUrl,
this.coverUrl = '',
this.isFavorite = false,
this.isDefault = false,
}); });
} }