diff --git a/airhub_app/lib/pages/music_creation_page.dart b/airhub_app/lib/pages/music_creation_page.dart index ac90f91..26bea18 100644 --- a/airhub_app/lib/pages/music_creation_page.dart +++ b/airhub_app/lib/pages/music_creation_page.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:google_fonts/google_fonts.dart'; import 'package:just_audio/just_audio.dart'; import '../services/music_generation_service.dart'; @@ -18,22 +19,24 @@ import '../theme/app_colors.dart' as appclr; /// Playlist track data class _Track { final int id; + final int? serverId; // Backend track ID (for delete/favorite) final String title; final String lyrics; String audioAsset; final bool isRemote; // true = URL from server, false = local asset + final bool isDefault; // Backend default track (undeletable) _Track({ required this.id, + this.serverId, required this.title, required this.lyrics, required this.audioAsset, this.isRemote = false, + this.isDefault = false, }); } -/// Server base URL — change this when deploying - class MusicCreationPage extends StatefulWidget { /// Whether this page is embedded as a tab (hides back button) final bool isTab; @@ -214,6 +217,9 @@ class _MusicCreationPageState extends State _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 @@ -222,6 +228,8 @@ class _MusicCreationPageState extends State setState(() { _totalTime = _formatDuration(duration); }); + }, onError: (e) { + debugPrint('durationStream error (ignored): $e'); }); // Listen to player state → detect track completion @@ -230,6 +238,8 @@ class _MusicCreationPageState extends State if (state.processingState == ProcessingState.completed) { _onTrackComplete(); } + }, onError: (e) { + debugPrint('playerStateStream error (ignored): $e'); }); // Pre-load the first track (don't auto-play) @@ -269,40 +279,49 @@ class _MusicCreationPageState extends State }); } - // ── Load historical songs from server ── - _loadHistoricalSongs(); + // ── Load playlist from backend API ── + _loadPlaylist(); } - // ── Load historical songs from server into playlist ── - Future _loadHistoricalSongs() async { + // ── Load playlist from backend API — replaces entire playlist ── + Future _loadPlaylist() async { final songs = await _genService.fetchPlaylist(); if (!mounted || songs.isEmpty) return; - // Collect titles already in playlist to avoid duplicates - final existingTitles = _playlist.map((t) => t.title).toSet(); + // Remember the currently playing track title to restore position + final currentTitle = _playlist.isNotEmpty + ? _playlist[_currentTrackIndex].title + : null; - final newTracks = <_Track>[]; - for (final song in songs) { - if (existingTitles.contains(song.title)) continue; - newTracks.add(_Track( - id: DateTime.now().millisecondsSinceEpoch + newTracks.length, - title: song.title, - lyrics: song.lyrics, - audioAsset: song.audioUrl, - isRemote: true, - )); + final backendTracks = songs.map((song) => _Track( + id: song.id ?? DateTime.now().millisecondsSinceEpoch, + serverId: song.id, + title: song.title, + lyrics: song.lyrics, + audioAsset: song.audioUrl, + 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(() { - // Insert server songs at the beginning (before hardcoded tracks) - _playlist.insertAll(0, newTracks); - // Shift current track index so it still points to the same track - _currentTrackIndex += newTracks.length; + _playlist.clear(); + _playlist.addAll(backendTracks); + _currentTrackIndex = newIndex; }); - 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 ── @@ -323,6 +342,16 @@ class _MusicCreationPageState extends State // Local preset track — load from assets 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) { debugPrint('Error loading track: $e'); if (mounted) { @@ -501,13 +530,23 @@ class _MusicCreationPageState extends State return; } - setState(() => _selectedMoodIndex = index); final mood = _moods[index]; - _generateMusic( - text: (mood['prompt'] as String).isNotEmpty - ? mood['prompt'] as String - : '咔咔今天想来点惊喜', - mood: mood['mood'] as String, + final text = (mood['prompt'] as String).isNotEmpty + ? mood['prompt'] 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 }); final newTrack = _Track( - id: DateTime.now().millisecondsSinceEpoch, + id: result.id ?? DateTime.now().millisecondsSinceEpoch, + serverId: result.id, title: result.title, lyrics: result.lyrics, audioAsset: result.audioUrl, @@ -581,6 +621,8 @@ class _MusicCreationPageState extends State setState(() { _playlist.insert(0, newTrack); + // Shift current track index since we inserted at 0 + _currentTrackIndex++; }); // Always show dialog, never auto-play @@ -597,7 +639,8 @@ class _MusicCreationPageState extends State }); final newTrack = _Track( - id: DateTime.now().millisecondsSinceEpoch, + id: result.id ?? DateTime.now().millisecondsSinceEpoch, + serverId: result.id, title: result.title, lyrics: result.lyrics, audioAsset: result.audioUrl, @@ -606,6 +649,8 @@ class _MusicCreationPageState extends State setState(() { _playlist.insert(0, newTrack); + // Shift current track index since we inserted at 0 + _currentTrackIndex++; }); if (_isPlaying) { @@ -1495,8 +1540,18 @@ class _MusicCreationPageState extends State controller: controller, onSubmit: (text) { Navigator.pop(ctx); - setState(() => _selectedMoodIndex = 5); - _generateMusic(text: text, mood: 'custom'); + showGlassDialog( + context: context, + title: '创作新歌', + description: '确认消耗 100 积分生成音乐?', + cancelText: '再想想', + confirmText: '开始创作', + onConfirm: () { + Navigator.pop(context); + setState(() => _selectedMoodIndex = 5); + _generateMusic(text: text, mood: 'custom'); + }, + ); }, ), ); diff --git a/airhub_app/lib/pages/story_detail_page.dart b/airhub_app/lib/pages/story_detail_page.dart index 25e3948..cd88c9f 100644 --- a/airhub_app/lib/pages/story_detail_page.dart +++ b/airhub_app/lib/pages/story_detail_page.dart @@ -2,6 +2,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:just_audio/just_audio.dart'; import '../theme/design_tokens.dart'; import '../widgets/gradient_button.dart'; @@ -87,18 +88,24 @@ class _StoryDetailPageState extends State _audioPosition = Duration.zero; }); } + }, onError: (e) { + debugPrint('playerStateStream error (ignored): $e'); }); // Listen to playback position for ring progress _positionSub = _audioPlayer.positionStream.listen((pos) { if (!mounted) return; setState(() => _audioPosition = pos); + }, onError: (e) { + debugPrint('positionStream error (ignored): $e'); }); // Listen to duration changes _audioPlayer.durationStream.listen((dur) { if (!mounted || dur == null) return; setState(() => _audioDuration = dur); + }, onError: (e) { + debugPrint('durationStream error (ignored): $e'); }); // Check if audio already exists @@ -250,6 +257,13 @@ class _StoryDetailPageState extends State if (mounted) { 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) { debugPrint('Audio play error: $e'); } diff --git a/airhub_app/lib/services/music_generation_service.dart b/airhub_app/lib/services/music_generation_service.dart index fceea1e..fd12468 100644 --- a/airhub_app/lib/services/music_generation_service.dart +++ b/airhub_app/lib/services/music_generation_service.dart @@ -1,6 +1,8 @@ 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. @@ -8,7 +10,10 @@ class MusicGenerationService { MusicGenerationService._(); static final MusicGenerationService instance = MusicGenerationService._(); - static const String _kServerBase = 'http://localhost:3000'; + Future _getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('access_token'); + } // ── Current task state ── bool _isGenerating = false; @@ -60,11 +65,15 @@ class MusicGenerationService { onProgress?.call(_progress, _currentStage, _statusMessage); try { + final token = await _getToken(); final request = http.Request( 'POST', - Uri.parse('$_kServerBase/api/create_music'), + 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(); @@ -73,14 +82,23 @@ class MusicGenerationService { ); 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; + errMsg = json['message'] as String? ?? errMsg; + } catch (_) {} + throw Exception(errMsg); } // Parse SSE stream String buffer = ''; String? newTitle; String? newLyrics; - String? newFilePath; + String? newAudioUrl; + String? newCoverUrl; + int? newTrackId; await for (final chunk in response.stream.transform(utf8.decoder)) { buffer += chunk; @@ -113,13 +131,14 @@ class MusicGenerationService { _updateProgress(90, stage, '音乐生成完成,正在保存...'); break; 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?; 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$'), ''); + if (newTitle == null || newTitle.isEmpty) { + newTitle = '咔咔新歌'; } _updateProgress(100, stage, '新歌出炉!'); break; @@ -148,11 +167,13 @@ class MusicGenerationService { _isGenerating = false; _progress = 0; - if (newFilePath != null) { + if (newAudioUrl != null) { final result = MusicGenResult( + id: newTrackId, title: newTitle ?? '新歌', lyrics: newLyrics ?? '', - audioUrl: '$_kServerBase/$newFilePath', + audioUrl: newAudioUrl, + coverUrl: newCoverUrl ?? '', ); // Always store as pending first; callback decides whether to consume @@ -163,7 +184,7 @@ class MusicGenerationService { debugPrint('Generate music error: $e'); _isGenerating = false; _progress = 0; - const errMsg = '网络开小差了,再试一次~'; + final errMsg = e.toString().replaceFirst('Exception: ', ''); _statusMessage = errMsg; if (onError != null) { onError!(errMsg); @@ -180,24 +201,33 @@ class MusicGenerationService { onProgress?.call(progress, stage, message); } - /// Fetch saved songs from the server (scans Capybara music/ folder). + /// Fetch playlist from backend API. Future> fetchPlaylist() async { try { + final token = await _getToken(); 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)); if (response.statusCode != 200) return []; - final data = jsonDecode(response.body) as Map; + final body = jsonDecode(response.body) as Map; + final data = body['data'] as Map? ?? {}; final list = data['playlist'] as List? ?? []; return list.map((item) { final m = item as Map; return MusicGenResult( + id: m['id'] as int?, title: m['title'] 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(); } 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 { + 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, }); }