- 接入豆包TTS V1 WebSocket API,支持故事朗读语音合成 - 新增 PillProgressButton 组件(药丸形进度按钮) - 新增 TTSService 单例,后台生成不中断 - 音频保存到 Capybara audio/ 目录 - 唱片架当前播放歌曲高亮(金色卡片+音波动效+喇叭图标) - 播放时气泡持续显示当前歌名,暂停后隐藏 - 音乐总监Prompt去固定模板,歌名不再重复 - 新增 API 参考文档(豆包语音合成) Co-authored-by: Cursor <cursoragent@cursor.com>
2277 lines
79 KiB
Dart
2277 lines
79 KiB
Dart
import 'dart:math';
|
||
import 'dart:ui';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:just_audio/just_audio.dart';
|
||
import '../services/music_generation_service.dart';
|
||
import '../widgets/animated_gradient_background.dart';
|
||
import '../widgets/ios_toast.dart';
|
||
import '../widgets/gradient_button.dart';
|
||
import '../widgets/glass_dialog.dart';
|
||
import '../theme/app_colors.dart' as appclr;
|
||
|
||
// ============================================================
|
||
// 音乐创作页面 — 水豚灵感电台
|
||
// 精确还原 music-creation.html 的所有视觉细节
|
||
// ============================================================
|
||
|
||
/// Playlist track data
|
||
class _Track {
|
||
final int id;
|
||
final String title;
|
||
final String lyrics;
|
||
String audioAsset;
|
||
final bool isRemote; // true = URL from server, false = local asset
|
||
|
||
_Track({
|
||
required this.id,
|
||
required this.title,
|
||
required this.lyrics,
|
||
required this.audioAsset,
|
||
this.isRemote = 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;
|
||
|
||
/// Whether this page is currently visible (for tab-based navigation)
|
||
final bool isVisible;
|
||
|
||
const MusicCreationPage({super.key, this.isTab = true, this.isVisible = true});
|
||
|
||
@override
|
||
State<MusicCreationPage> createState() => _MusicCreationPageState();
|
||
}
|
||
|
||
class _MusicCreationPageState extends State<MusicCreationPage>
|
||
with TickerProviderStateMixin {
|
||
// ── State ──
|
||
bool _isPlaying = false;
|
||
bool _isGenerating = false;
|
||
double _genProgress = 0.0; // 0~100, generation progress ring
|
||
bool _isFlipped = false;
|
||
int? _selectedMoodIndex;
|
||
double _progress = 0.0;
|
||
String _currentTime = '0:00';
|
||
String _totalTime = '3:24';
|
||
int _currentTrackIndex = 0;
|
||
bool _isDragging = false; // True while user drags the slider
|
||
|
||
// Audio player (just_audio)
|
||
late AudioPlayer _audioPlayer;
|
||
|
||
// Speech bubble
|
||
String? _speechText;
|
||
bool _speechVisible = false;
|
||
|
||
// ── Animation Controllers ──
|
||
late AnimationController _vinylSpinController;
|
||
late AnimationController _tonearmController;
|
||
late Animation<double> _tonearmAngle;
|
||
late AnimationController _flipController;
|
||
late Animation<double> _flipAnimation;
|
||
late AnimationController _genRingController;
|
||
late AnimationController _mysteryShimmerController;
|
||
|
||
// ── Playlist Data (matching HTML) ──
|
||
final List<_Track> _playlist = [
|
||
_Track(
|
||
id: 1,
|
||
title: '卡皮巴拉蹦蹦蹦',
|
||
audioAsset: 'assets/www/music/卡皮巴拉蹦蹦蹦.mp3',
|
||
lyrics: '卡皮巴拉\n啦啦啦啦\n卡皮巴拉\n啦啦啦啦\n\n'
|
||
'卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n'
|
||
'卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n卡皮巴拉 不要停\n跟着我 一起疯\n\n'
|
||
'一口菜叶 卡一巴\n两口草莓 巴一拉\n三口西瓜 啦一啦\n嘴巴圆圆 哈哈哈 (哦耶)',
|
||
),
|
||
_Track(
|
||
id: 2,
|
||
title: '卡皮巴拉快乐水',
|
||
audioAsset: 'assets/www/music/卡皮巴拉快乐水.mp3',
|
||
lyrics: '卡皮巴拉\n卡皮巴拉\n卡皮巴拉\n啦啦啦啦\n\n'
|
||
'卡皮巴拉趴地上\n一动不动好嚣张\n心里其实在上网\n刷到我就笑出响 (哈哈哈)\n\n'
|
||
'卡皮巴拉 巴拉巴拉\n压力来啦 它说算啦\n一点不慌 就是躺啦\n世界太吵 它在发呆呀',
|
||
),
|
||
_Track(
|
||
id: 3,
|
||
title: '卡皮巴拉快乐营业',
|
||
audioAsset: 'assets/www/music/卡皮巴拉快乐营业.mp3',
|
||
lyrics: '早八打工人\n心却躺平人\n桌面壁纸换上\n卡皮巴拉一整屏 (嘿)\n\n'
|
||
'它坐在河边\n像个退休中年\n我卷生卷死\n它只发呆发呆再发呆\n\n'
|
||
'卡皮巴拉 卡皮巴拉 拉\n看你就把压力清空啦 (啊对对对)\n谁骂我韭菜我就回他\n我已经转职水豚啦',
|
||
),
|
||
_Track(
|
||
id: 4,
|
||
title: '卡皮巴拉快乐趴',
|
||
audioAsset: 'assets/www/music/卡皮巴拉快乐趴.mp3',
|
||
lyrics: '今天不上班\n卡皮巴拉躺平在沙滩\n小小太阳帽\n草帽底下梦见一整片菜园 (好香哦)\n\n'
|
||
'卡皮巴拉啦啦啦\n快乐像病毒一样传染呀\n你一笑 它一哈\n全场都在哈哈哈',
|
||
),
|
||
];
|
||
|
||
// ── Mood cards — prompt 设计为宽泛场景,保证同一卡片每次生成不同 ──
|
||
static const List<Map<String, dynamic>> _moods = [
|
||
{
|
||
'icon': Icons.spa_outlined, 'color': 0xFFB8D4E3,
|
||
'title': 'Chill Lofi', 'desc': '慵懒 · 治愈 · 水声',
|
||
'prompt': '慵懒的午后,泡在温泉里听水声发呆,什么都不想做',
|
||
'mood': 'chill',
|
||
},
|
||
{
|
||
'icon': Icons.directions_run, 'color': 0xFFF5C6A5,
|
||
'title': 'Happy Funk', 'desc': '活力 · 奔跑 · 阳光',
|
||
'prompt': '阳光灿烂的日子,在草地上奔跑撒欢,心情超级好',
|
||
'mood': 'happy',
|
||
},
|
||
{
|
||
'icon': Icons.nights_stay_outlined, 'color': 0xFFCBB8E0,
|
||
'title': 'Deep Sleep', 'desc': '白噪音 · 助眠 · 梦境',
|
||
'prompt': '夜深了,窗外下着小雨,盖着被子准备入睡',
|
||
'mood': 'sleepy',
|
||
},
|
||
{
|
||
'icon': Icons.psychology_outlined, 'color': 0xFFA8D8C8,
|
||
'title': 'Focus Flow', 'desc': '心流 · 专注 · 效率',
|
||
'prompt': '安静的书房里,沏一杯茶,沉浸在自己的世界',
|
||
'mood': 'chill',
|
||
},
|
||
{
|
||
'icon': Icons.redeem_outlined, 'color': 0xFFD4A0E8,
|
||
'title': '盲盒惊喜', 'desc': 'AI 随机生成神曲',
|
||
'prompt': '', // 空 prompt,让 LLM 自由发挥
|
||
'mood': 'random',
|
||
},
|
||
{
|
||
'icon': Icons.auto_awesome, 'color': 0xFFECCFA8,
|
||
'title': '自由创作', 'desc': '输入灵感 · 生成音乐',
|
||
'prompt': '', // 用户自定义输入
|
||
'mood': 'custom',
|
||
},
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
// Vinyl spin: continuous 6s rotation (HTML: animation: spin 6s linear infinite)
|
||
_vinylSpinController = AnimationController(
|
||
duration: const Duration(seconds: 6),
|
||
vsync: this,
|
||
);
|
||
|
||
// Tonearm: -55deg (rest) → -25deg (playing)
|
||
// HTML: transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)
|
||
_tonearmController = AnimationController(
|
||
duration: const Duration(milliseconds: 600),
|
||
vsync: this,
|
||
);
|
||
_tonearmAngle = Tween<double>(begin: -55, end: -25).animate(
|
||
CurvedAnimation(
|
||
parent: _tonearmController,
|
||
curve: const Cubic(0.4, 0.0, 0.2, 1.0),
|
||
),
|
||
);
|
||
|
||
// Flip: 0 → π (HTML: transition: transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1))
|
||
_flipController = AnimationController(
|
||
duration: const Duration(milliseconds: 600),
|
||
vsync: this,
|
||
);
|
||
_flipAnimation = Tween<double>(begin: 0, end: pi).animate(
|
||
CurvedAnimation(
|
||
parent: _flipController,
|
||
curve: const Cubic(0.4, 0.0, 0.2, 1.0),
|
||
),
|
||
);
|
||
|
||
// Generation progress ring
|
||
_genRingController = AnimationController(
|
||
duration: const Duration(milliseconds: 600),
|
||
vsync: this,
|
||
);
|
||
|
||
// Mystery box diagonal shimmer — 3s loop
|
||
_mysteryShimmerController = AnimationController(
|
||
duration: const Duration(milliseconds: 3000),
|
||
vsync: this,
|
||
)..repeat();
|
||
|
||
// ── Audio Player Setup ──
|
||
_audioPlayer = AudioPlayer();
|
||
|
||
// Listen to position → update progress bar & time label
|
||
_audioPlayer.positionStream.listen((position) {
|
||
if (!mounted || _isDragging) return;
|
||
final duration = _audioPlayer.duration;
|
||
if (duration != null && duration.inMilliseconds > 0) {
|
||
setState(() {
|
||
_progress =
|
||
(position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
|
||
_currentTime = _formatDuration(position);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Listen to duration → update total time label
|
||
_audioPlayer.durationStream.listen((duration) {
|
||
if (!mounted || duration == null) return;
|
||
setState(() {
|
||
_totalTime = _formatDuration(duration);
|
||
});
|
||
});
|
||
|
||
// Listen to player state → detect track completion
|
||
_audioPlayer.playerStateStream.listen((state) {
|
||
if (!mounted) return;
|
||
if (state.processingState == ProcessingState.completed) {
|
||
_onTrackComplete();
|
||
}
|
||
});
|
||
|
||
// Pre-load the first track (don't auto-play)
|
||
_loadTrack(_currentTrackIndex);
|
||
|
||
// ── Bind to generation service & check for pending results ──
|
||
_bindGenServiceCallbacks();
|
||
|
||
// If generation was running while we were away, restore UI state
|
||
if (_genService.isGenerating) {
|
||
_isGenerating = true;
|
||
_genProgress = _genService.progress;
|
||
_showSpeech(_genService.statusMessage, duration: 0);
|
||
}
|
||
|
||
// If a song was generated while we were away, show dialog (don't auto-play)
|
||
final pending = _genService.consumePendingResult();
|
||
if (pending != null) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _handlePendingResult(pending);
|
||
});
|
||
}
|
||
|
||
// If generation failed while we were away, show error bubble
|
||
final pendingError = _genService.consumePendingError();
|
||
if (pendingError != null) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isGenerating = false;
|
||
_genProgress = 0;
|
||
_genStickyText = null;
|
||
_selectedMoodIndex = null;
|
||
});
|
||
_showSpeech(pendingError);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Load historical songs from server ──
|
||
_loadHistoricalSongs();
|
||
}
|
||
|
||
// ── Load historical songs from server into playlist ──
|
||
Future<void> _loadHistoricalSongs() 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();
|
||
|
||
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,
|
||
));
|
||
}
|
||
|
||
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;
|
||
});
|
||
|
||
debugPrint('Loaded ${newTracks.length} historical songs from server');
|
||
}
|
||
|
||
// ── Duration formatter ──
|
||
String _formatDuration(Duration d) {
|
||
final minutes = d.inMinutes;
|
||
final seconds = d.inSeconds % 60;
|
||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
// ── Load a track into the audio player (without playing) ──
|
||
Future<void> _loadTrack(int index) async {
|
||
try {
|
||
final track = _playlist[index];
|
||
if (track.isRemote) {
|
||
// Server-generated track — load from URL
|
||
await _audioPlayer.setUrl(track.audioAsset);
|
||
} else {
|
||
// Local preset track — load from assets
|
||
await _audioPlayer.setAsset(track.audioAsset);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Error loading track: $e');
|
||
if (mounted) {
|
||
_showSpeech('音频加载失败,请重试');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── When a track finishes, play the next one ──
|
||
void _onTrackComplete() {
|
||
final nextIndex = (_currentTrackIndex + 1) % _playlist.length;
|
||
_playTrack(nextIndex);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant MusicCreationPage oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
// When page becomes visible again (tab switch back)
|
||
if (widget.isVisible && !oldWidget.isVisible) {
|
||
// Re-bind callbacks
|
||
_bindGenServiceCallbacks();
|
||
|
||
// If generation is still running, restore progress UI + crawl animation
|
||
if (_genService.isGenerating) {
|
||
final currentProgress = _genService.progress;
|
||
final currentStage = _genService.currentStage;
|
||
setState(() {
|
||
_isGenerating = true;
|
||
_genProgress = currentProgress;
|
||
});
|
||
_showSpeech(_genService.statusMessage, duration: 0);
|
||
|
||
// Restart crawl animation based on current stage
|
||
if (currentStage == 'lyrics') {
|
||
_crawlProgress(currentProgress, 25, 8000);
|
||
} else if (currentStage == 'music') {
|
||
_crawlProgress(currentProgress, 85, 60000);
|
||
}
|
||
}
|
||
|
||
// If a song finished while we were away, show the dialog after build
|
||
final pending = _genService.consumePendingResult();
|
||
if (pending != null) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _handlePendingResult(pending);
|
||
});
|
||
}
|
||
|
||
// If generation failed while we were away, show error bubble
|
||
final pendingError = _genService.consumePendingError();
|
||
if (pendingError != null) {
|
||
setState(() {
|
||
_isGenerating = false;
|
||
_genProgress = 0;
|
||
_genStickyText = null;
|
||
_selectedMoodIndex = null;
|
||
});
|
||
_showSpeech(pendingError);
|
||
}
|
||
}
|
||
// When page becomes hidden (tab switch away)
|
||
if (!widget.isVisible && oldWidget.isVisible) {
|
||
_unbindGenServiceCallbacks();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unbindGenServiceCallbacks();
|
||
_audioPlayer.dispose();
|
||
_vinylSpinController.dispose();
|
||
_tonearmController.dispose();
|
||
_flipController.dispose();
|
||
_genRingController.dispose();
|
||
_mysteryShimmerController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ── Playback Controls ──
|
||
void _togglePlay() async {
|
||
setState(() {
|
||
_isPlaying = !_isPlaying;
|
||
if (_isPlaying) {
|
||
if (!_isFlipped) _vinylSpinController.repeat();
|
||
_tonearmController.forward();
|
||
} else {
|
||
_vinylSpinController.stop();
|
||
_tonearmController.reverse();
|
||
}
|
||
});
|
||
|
||
// Actually play or pause audio
|
||
try {
|
||
if (_isPlaying) {
|
||
// Show now-playing bubble immediately (before await)
|
||
_playStickyText = '正在播放: ${_playlist[_currentTrackIndex].title}';
|
||
setState(() {
|
||
_speechText = _playStickyText;
|
||
_speechVisible = true;
|
||
});
|
||
await _audioPlayer.play();
|
||
} else {
|
||
await _audioPlayer.pause();
|
||
// Hide bubble on pause
|
||
_playStickyText = null;
|
||
setState(() => _speechVisible = false);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Playback error: $e');
|
||
if (mounted) {
|
||
_showSpeech('播放出错了,请重试');
|
||
// Revert UI state on error
|
||
setState(() {
|
||
_isPlaying = false;
|
||
_playStickyText = null;
|
||
_vinylSpinController.stop();
|
||
_tonearmController.reverse();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
void _flipVinyl() {
|
||
setState(() {
|
||
_isFlipped = !_isFlipped;
|
||
if (_isFlipped) {
|
||
_flipController.forward();
|
||
_vinylSpinController.stop(); // Pause spin while flipped (HTML behavior)
|
||
} else {
|
||
_flipController.reverse();
|
||
if (_isPlaying) _vinylSpinController.repeat();
|
||
}
|
||
});
|
||
}
|
||
|
||
void _playTrack(int index) async {
|
||
setState(() {
|
||
_currentTrackIndex = index;
|
||
_progress = 0;
|
||
_currentTime = '0:00';
|
||
});
|
||
|
||
// Flip back to front if flipped
|
||
if (_isFlipped) _flipVinyl();
|
||
|
||
// Load the new track
|
||
await _loadTrack(index);
|
||
|
||
// Start playing
|
||
if (!_isPlaying) {
|
||
_togglePlay();
|
||
} else {
|
||
// Already playing — seek to start & play
|
||
try {
|
||
await _audioPlayer.seek(Duration.zero);
|
||
await _audioPlayer.play();
|
||
} catch (e) {
|
||
debugPrint('Play track error: $e');
|
||
}
|
||
}
|
||
|
||
_playStickyText = '正在播放: ${_playlist[index].title}';
|
||
_showSpeech(_playStickyText!, duration: 0);
|
||
}
|
||
|
||
// ── Mood Selection ──
|
||
void _selectMood(int index) {
|
||
if (_isGenerating) {
|
||
_showSpeech('音乐正在生成中,请稍等哦~');
|
||
return;
|
||
}
|
||
|
||
// Last card = custom input
|
||
if (index == 5) {
|
||
_showInputModal();
|
||
return;
|
||
}
|
||
|
||
setState(() => _selectedMoodIndex = index);
|
||
final mood = _moods[index];
|
||
_generateMusic(
|
||
text: (mood['prompt'] as String).isNotEmpty
|
||
? mood['prompt'] as String
|
||
: '咔咔今天想来点惊喜',
|
||
mood: mood['mood'] as String,
|
||
);
|
||
}
|
||
|
||
// ── Generation via singleton service (survives page navigation) ──
|
||
final _genService = MusicGenerationService.instance;
|
||
|
||
void _bindGenServiceCallbacks() {
|
||
_genService.onProgress = (progress, stage, message) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_genProgress = progress;
|
||
_isGenerating = true;
|
||
});
|
||
_showSpeech(message, duration: 0);
|
||
|
||
// Start crawl animations for long stages
|
||
if (stage == 'lyrics') _crawlProgress(10, 25, 8000);
|
||
if (stage == 'music') _crawlProgress(30, 85, 120000);
|
||
};
|
||
|
||
_genService.onComplete = (result) {
|
||
if (!mounted || !widget.isVisible) return;
|
||
// Page is visible — consume the pending result and handle it
|
||
_genService.consumePendingResult();
|
||
_handleGenResult(result);
|
||
};
|
||
|
||
_genService.onError = (error) {
|
||
if (!mounted) return;
|
||
_showSpeech(error);
|
||
setState(() {
|
||
_isGenerating = false;
|
||
_genProgress = 0;
|
||
_genStickyText = null;
|
||
_selectedMoodIndex = null;
|
||
});
|
||
};
|
||
}
|
||
|
||
void _unbindGenServiceCallbacks() {
|
||
_genService.onProgress = null;
|
||
_genService.onComplete = null;
|
||
_genService.onError = null;
|
||
}
|
||
|
||
void _generateMusic({required String text, required String mood}) {
|
||
setState(() {
|
||
_isGenerating = true;
|
||
_genProgress = 5;
|
||
});
|
||
_showSpeech('正在连接 AI...', duration: 0);
|
||
_genService.generate(text: text, mood: mood);
|
||
}
|
||
|
||
/// Handle a pending result when user returns to the page — always ask, never auto-play.
|
||
void _handlePendingResult(MusicGenResult result) {
|
||
setState(() {
|
||
_isGenerating = false;
|
||
_genProgress = 0;
|
||
_genStickyText = null;
|
||
_selectedMoodIndex = null;
|
||
});
|
||
|
||
final newTrack = _Track(
|
||
id: DateTime.now().millisecondsSinceEpoch,
|
||
title: result.title,
|
||
lyrics: result.lyrics,
|
||
audioAsset: result.audioUrl,
|
||
isRemote: true,
|
||
);
|
||
|
||
setState(() {
|
||
_playlist.insert(0, newTrack);
|
||
});
|
||
|
||
// Always show dialog, never auto-play
|
||
_showConfirmDialog(newTrack.title);
|
||
}
|
||
|
||
/// Handle a completed generation result (live — user is on the page).
|
||
void _handleGenResult(MusicGenResult result) {
|
||
setState(() {
|
||
_isGenerating = false;
|
||
_genProgress = 0;
|
||
_genStickyText = null;
|
||
_selectedMoodIndex = null;
|
||
});
|
||
|
||
final newTrack = _Track(
|
||
id: DateTime.now().millisecondsSinceEpoch,
|
||
title: result.title,
|
||
lyrics: result.lyrics,
|
||
audioAsset: result.audioUrl,
|
||
isRemote: true,
|
||
);
|
||
|
||
setState(() {
|
||
_playlist.insert(0, newTrack);
|
||
});
|
||
|
||
if (_isPlaying) {
|
||
_showConfirmDialog(newTrack.title);
|
||
} else {
|
||
_playTrack(0);
|
||
}
|
||
}
|
||
|
||
// ── Crawl progress: slowly animate from→to over durationMs ──
|
||
int _crawlId = 0; // Cancel token — only the latest crawl runs
|
||
|
||
void _crawlProgress(double from, double to, int durationMs) {
|
||
_crawlId++; // Invalidate any previous crawl
|
||
final myId = _crawlId;
|
||
final steps = durationMs ~/ 300;
|
||
final increment = (to - from) / steps;
|
||
int step = 0;
|
||
Future.doWhile(() async {
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
if (myId != _crawlId) return false; // Cancelled by a newer crawl
|
||
if (!mounted || !_isGenerating || _genProgress >= to) return false;
|
||
step++;
|
||
setState(() => _genProgress = (from + increment * step).clamp(from, to));
|
||
return step < steps && _isGenerating;
|
||
});
|
||
}
|
||
|
||
// ── Clean lyrics: strip structure tags, JSON artifacts & normalize ──
|
||
String _cleanLyrics(String raw) {
|
||
String s = raw;
|
||
// Replace literal \n with real newlines
|
||
s = s.replaceAll(r'\n', '\n');
|
||
// Remove JSON string quote artifacts (" ")
|
||
s = s.replaceAll(RegExp(r'"\s*"'), '');
|
||
s = s.replaceAll('"', '');
|
||
// Remove structure tags: [verse 1], [chorus], [outro], [bridge], etc.
|
||
s = s.replaceAll(
|
||
RegExp(r'\[(verse|chorus|bridge|outro|intro|hook|pre-chorus|interlude|inst)\s*\d*\]\s*',
|
||
caseSensitive: false),
|
||
'',
|
||
);
|
||
// Strip leading/trailing whitespace from each line
|
||
s = s.split('\n').map((line) => line.trim()).join('\n');
|
||
// Collapse 3+ newlines into one blank line
|
||
s = s.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||
return s.trim();
|
||
}
|
||
|
||
// ── Speech Bubble ──
|
||
String? _genStickyText; // Persistent text during generation
|
||
String? _playStickyText; // Persistent text during playback
|
||
|
||
void _showSpeech(String text, {int duration = 3000}) {
|
||
// If this is a generation-related message (duration == 0), save it as sticky
|
||
if (duration == 0 && _isGenerating) {
|
||
_genStickyText = text;
|
||
}
|
||
|
||
setState(() {
|
||
_speechText = text;
|
||
_speechVisible = true;
|
||
});
|
||
if (duration > 0) {
|
||
Future.delayed(Duration(milliseconds: duration), () {
|
||
if (!mounted) return;
|
||
if (_speechText == text) {
|
||
// If still generating, restore the sticky generation message
|
||
if (_isGenerating && _genStickyText != null) {
|
||
setState(() {
|
||
_speechText = _genStickyText;
|
||
_speechVisible = true;
|
||
});
|
||
} else if (_isPlaying && _playStickyText != null) {
|
||
// If playing, restore the now-playing message
|
||
setState(() {
|
||
_speechText = _playStickyText;
|
||
_speechVisible = true;
|
||
});
|
||
} else {
|
||
setState(() => _speechVisible = false);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// BUILD
|
||
// ══════════════════════════════════════════════════════════════
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Stack(
|
||
children: [
|
||
// Background - reuse the global animated gradient
|
||
const AnimatedGradientBackground(),
|
||
|
||
// Content
|
||
SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
children: [
|
||
_buildHeader(),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 56),
|
||
child: Column(
|
||
children: [
|
||
const SizedBox(height: 2),
|
||
_buildPlayerArea(),
|
||
const SizedBox(height: 6), // HTML: gap 6px
|
||
_buildProgressBar(),
|
||
const SizedBox(height: 6),
|
||
_buildMoodSection(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// HEADER — matches HTML .page-header
|
||
// ══════════════════════════════════════════════════════════════
|
||
Widget _buildHeader() {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||
child: Row(
|
||
children: [
|
||
// Back button (hidden when used as tab)
|
||
if (!widget.isTab)
|
||
GestureDetector(
|
||
onTap: () => Navigator.of(context).pop(),
|
||
child: _headerIconButton(
|
||
const Icon(Icons.arrow_back_ios_new,
|
||
size: 18, color: Color(0xFF4B5563)),
|
||
),
|
||
)
|
||
else
|
||
const SizedBox(width: 40), // Spacer to center title
|
||
|
||
// Title — HTML: .page-title { font-size: 17px; font-weight: 600 }
|
||
Expanded(
|
||
child: Text(
|
||
'灵感电台',
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.outfit(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF1F2937),
|
||
letterSpacing: -0.17, // HTML: -0.01em
|
||
),
|
||
),
|
||
),
|
||
|
||
// Playlist button — HTML: .icon-btn with grid SVG
|
||
GestureDetector(
|
||
onTap: _showPlaylistModal,
|
||
child: _headerIconButton(
|
||
const Icon(Icons.grid_view_rounded,
|
||
size: 22, color: Color(0xFF4B5563)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _headerIconButton(Widget child) {
|
||
// HTML: .icon-btn { background: rgba(255,255,255,0.6); backdrop-filter: blur(8px);
|
||
// width: 40px; height: 40px; border-radius: 12px; }
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.6),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: child,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// PLAYER AREA — vinyl + speech bubble
|
||
// ══════════════════════════════════════════════════════════════
|
||
Widget _buildPlayerArea() {
|
||
// HTML: .player-area { width: 210px; margin: 0 auto; }
|
||
return SizedBox(
|
||
width: 260, // Extra space for bubble overflow
|
||
height: 228,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
alignment: Alignment.center,
|
||
children: [
|
||
// Vinyl player — centered
|
||
Positioned(
|
||
left: 25,
|
||
top: 8,
|
||
child: _buildVinylWrapper(),
|
||
),
|
||
// Speech bubble — positioned top-right
|
||
// Always show during playback; otherwise use _speechVisible
|
||
if ((_speechVisible && _speechText != null) ||
|
||
(_isPlaying && _playStickyText != null))
|
||
Positioned(
|
||
top: 0,
|
||
right: -24, // HTML: right: -24px
|
||
child: _buildSpeechBubble(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Vinyl Wrapper with 3D flip ──
|
||
Widget _buildVinylWrapper() {
|
||
// HTML: .player-visual-wrapper { perspective: 800px; width: 210px; height: 210px;
|
||
// filter: drop-shadow(0 20px 40px rgba(0,0,0,0.2)); }
|
||
return SizedBox(
|
||
width: 210,
|
||
height: 210,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
alignment: Alignment.center,
|
||
children: [
|
||
// Vinyl disc (flippable)
|
||
GestureDetector(
|
||
onTap: _flipVinyl,
|
||
child: Container(
|
||
width: 210,
|
||
height: 210,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.2),
|
||
offset: const Offset(0, 20),
|
||
blurRadius: 40,
|
||
),
|
||
],
|
||
),
|
||
child: AnimatedBuilder(
|
||
animation: _flipAnimation,
|
||
builder: (context, child) {
|
||
final angle = _flipAnimation.value;
|
||
final showBack = angle > pi / 2;
|
||
|
||
return Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()
|
||
..setEntry(3, 2, 0.00125)
|
||
..rotateY(angle),
|
||
child: showBack
|
||
? Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()..rotateY(pi),
|
||
child: _buildVinylBack(),
|
||
)
|
||
: _buildVinylFront(),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
|
||
// Generation progress ring — always on top, regardless of flip
|
||
if (_isGenerating || _genProgress > 0)
|
||
Positioned(
|
||
left: -7,
|
||
top: -7,
|
||
width: 224,
|
||
height: 224,
|
||
child: IgnorePointer(
|
||
child: AnimatedOpacity(
|
||
opacity: _isGenerating ? 1.0 : 0.0,
|
||
duration: const Duration(milliseconds: 400),
|
||
child: CustomPaint(
|
||
painter: _GenProgressRingPainter(
|
||
progress: _genProgress / 100.0,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Vinyl Front: spinning disc + album cover + tonearm + loading ──
|
||
Widget _buildVinylFront() {
|
||
return SizedBox(
|
||
width: 210,
|
||
height: 210,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// Spinning disc
|
||
AnimatedBuilder(
|
||
animation: _vinylSpinController,
|
||
builder: (context, child) {
|
||
return Transform.rotate(
|
||
angle: _vinylSpinController.value * 2 * pi,
|
||
child: child,
|
||
);
|
||
},
|
||
child: Container(
|
||
width: 210,
|
||
height: 210,
|
||
decoration: const BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Color(0xFF18181B), // zinc-900
|
||
),
|
||
child: CustomPaint(
|
||
painter: _VinylDiscPainter(),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Album cover (static — HTML: no rotation on cover)
|
||
// HTML: .album-cover { width: 130px; height: 130px; border-radius: 50%;
|
||
// border: 2px solid rgba(236,207,168,0.6); }
|
||
Center(
|
||
child: Container(
|
||
width: 130,
|
||
height: 130,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: const Color(0xFFECCFA8).withOpacity(0.6),
|
||
width: 2,
|
||
),
|
||
boxShadow: [
|
||
const BoxShadow(
|
||
color: Color(0x1A000000), // rgba(0,0,0,0.1)
|
||
blurRadius: 20,
|
||
spreadRadius: 4,
|
||
),
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.5),
|
||
blurRadius: 20,
|
||
// inset shadow approximation
|
||
),
|
||
],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Image.asset(
|
||
'assets/www/Capybara.png',
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => Container(
|
||
color: const Color(0xFF27272A),
|
||
child: const Icon(Icons.music_note,
|
||
color: Colors.white54, size: 40),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Tonearm
|
||
// HTML: .tonearm { position: absolute; top: -8px; right: 18px;
|
||
// transform-origin: 62px 12px; transform: rotate(-55deg); }
|
||
Positioned(
|
||
top: -8,
|
||
right: 18,
|
||
child: AnimatedBuilder(
|
||
animation: _tonearmAngle,
|
||
builder: (context, child) {
|
||
return Transform(
|
||
alignment: Alignment(
|
||
(62 - 40) / 40, // Convert 62px from left of 80px width → alignment
|
||
(12 - 50) / 50, // Convert 12px from top of 100px height
|
||
),
|
||
transform: Matrix4.identity()
|
||
..rotateZ(_tonearmAngle.value * pi / 180),
|
||
child: child,
|
||
);
|
||
},
|
||
child: SizedBox(
|
||
width: 80,
|
||
height: 100,
|
||
child: CustomPaint(
|
||
painter: _TonearmPainter(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Loading overlay
|
||
// HTML: .loading-overlay { background: rgba(0,0,0,0.3); }
|
||
if (_isGenerating)
|
||
Container(
|
||
width: 210,
|
||
height: 210,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.black.withOpacity(0.3),
|
||
),
|
||
child: const Center(
|
||
child: SizedBox(
|
||
width: 40,
|
||
height: 40,
|
||
child: CircularProgressIndicator(
|
||
color: Colors.white,
|
||
strokeWidth: 3,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Vinyl Back: lyrics ──
|
||
Widget _buildVinylBack() {
|
||
final track = _playlist[_currentTrackIndex];
|
||
// HTML: .vinyl-back { background: #18181B; border: 3px solid rgba(236,207,168,0.25); }
|
||
return Container(
|
||
width: 210,
|
||
height: 210,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: const Color(0xFF18181B),
|
||
border: Border.all(
|
||
color: const Color(0xFFECCFA8).withOpacity(0.25),
|
||
width: 3,
|
||
),
|
||
),
|
||
child: CustomPaint(
|
||
painter: _VinylBackGroovesPainter(),
|
||
child: Center(
|
||
child: Container(
|
||
width: 150,
|
||
height: 150,
|
||
decoration: BoxDecoration(
|
||
// Dark overlay to cover groove lines behind text
|
||
color: const Color(0xFF18181B).withOpacity(0.75),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(10),
|
||
child: Text(
|
||
track.lyrics.isNotEmpty
|
||
? _cleanLyrics(track.lyrics)
|
||
: '生成音乐后\n点我看歌词',
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 12,
|
||
height: 1.6,
|
||
color: track.lyrics.isNotEmpty
|
||
? Colors.white.withOpacity(0.92)
|
||
: Colors.white.withOpacity(0.4),
|
||
fontStyle: track.lyrics.isEmpty
|
||
? FontStyle.italic
|
||
: FontStyle.normal,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Speech Bubble ──
|
||
Widget _buildSpeechBubble() {
|
||
// HTML: .capy-speech-bubble with clip-path iMessage-style tail at bottom-left
|
||
const tailH = 8.0;
|
||
// During playback, always show the playing text even if _speechVisible is false
|
||
final bool showBubble = _speechVisible || (_isPlaying && _playStickyText != null);
|
||
final String bubbleText = (_isPlaying && _playStickyText != null && !_speechVisible)
|
||
? _playStickyText!
|
||
: (_speechText ?? '');
|
||
|
||
return AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 200),
|
||
opacity: showBubble ? 1.0 : 0.0,
|
||
child: AnimatedScale(
|
||
duration: const Duration(milliseconds: 350),
|
||
scale: showBubble ? 1.0 : 0.7,
|
||
curve: const Cubic(0.34, 1.56, 0.64, 1.0),
|
||
alignment: Alignment.bottomLeft,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Bubble body
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFDF7ED).withOpacity(0.93),
|
||
borderRadius: BorderRadius.circular(14),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: const Color(0xFFECCFA8).withOpacity(0.45),
|
||
blurRadius: 0.5,
|
||
),
|
||
BoxShadow(
|
||
color: const Color(0xFF8B5E3C).withOpacity(0.10),
|
||
offset: const Offset(0, 3),
|
||
blurRadius: 12,
|
||
),
|
||
],
|
||
),
|
||
child: Text(
|
||
bubbleText,
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 12.5,
|
||
fontWeight: FontWeight.w500,
|
||
color: const Color(0xFF6B4423),
|
||
),
|
||
),
|
||
),
|
||
// Tail (小角角) — bottom-left, matching HTML clip-path tail
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 14),
|
||
child: CustomPaint(
|
||
size: const Size(12, tailH),
|
||
painter: _BubbleTailPainter(
|
||
color: const Color(0xFFFDF7ED).withOpacity(0.93),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// PROGRESS BAR — matches HTML .progress-section
|
||
// ══════════════════════════════════════════════════════════════
|
||
Widget _buildProgressBar() {
|
||
return Container(
|
||
height: 56,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
alignment: Alignment.center,
|
||
children: [
|
||
// Background bar — flush with mood cards
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: 4,
|
||
bottom: 4,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(24),
|
||
child: BackdropFilter(
|
||
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
||
child: Container(
|
||
padding: const EdgeInsets.only(left: 52, right: 16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.6),
|
||
borderRadius: BorderRadius.circular(24),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// Current time
|
||
SizedBox(
|
||
width: 36,
|
||
child: Text(
|
||
_currentTime,
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 12,
|
||
color: const Color(0xFF6B7280),
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
),
|
||
// Slider
|
||
Expanded(
|
||
child: SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
activeTrackColor: const Color(0xFFE8C9A8),
|
||
inactiveTrackColor: const Color(0xFFE5E5EA),
|
||
thumbColor: Colors.white,
|
||
trackHeight: 6,
|
||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9),
|
||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
|
||
),
|
||
child: Slider(
|
||
value: _progress,
|
||
onChangeStart: (_) => _isDragging = true,
|
||
onChanged: (v) {
|
||
setState(() => _progress = v);
|
||
// Update the time label while dragging
|
||
final duration = _audioPlayer.duration;
|
||
if (duration != null) {
|
||
final seekPos = Duration(
|
||
milliseconds: (v * duration.inMilliseconds).toInt(),
|
||
);
|
||
setState(() => _currentTime = _formatDuration(seekPos));
|
||
}
|
||
},
|
||
onChangeEnd: (v) {
|
||
_isDragging = false;
|
||
final duration = _audioPlayer.duration;
|
||
if (duration != null) {
|
||
_audioPlayer.seek(Duration(
|
||
milliseconds: (v * duration.inMilliseconds).toInt(),
|
||
));
|
||
}
|
||
},
|
||
),
|
||
),
|
||
),
|
||
// Total time
|
||
SizedBox(
|
||
width: 36,
|
||
child: Text(
|
||
_totalTime,
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 12,
|
||
color: const Color(0xFF6B7280),
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Play/Pause button (sits on top, aligned with capsule left edge)
|
||
Positioned(
|
||
left: 0,
|
||
child: GestureDetector(
|
||
onTap: _togglePlay,
|
||
child: Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.white.withOpacity(0.9),
|
||
border: Border.all(color: Colors.black.withOpacity(0.08)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.1),
|
||
offset: const Offset(0, 2),
|
||
blurRadius: 8,
|
||
),
|
||
],
|
||
),
|
||
child: Icon(
|
||
_isPlaying ? Icons.pause : Icons.play_arrow,
|
||
size: 24,
|
||
color: const Color(0xFF6B7280),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// MOOD SECTION — matches HTML .inspiration-section + .mood-grid
|
||
// ══════════════════════════════════════════════════════════════
|
||
Widget _buildMoodSection() {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// HTML: .section-label { font-size: 13px; font-weight: 600; color: var(--text-muted);
|
||
// margin-bottom: 4px; padding-left: 4px; letter-spacing: 0.02em; }
|
||
// HTML: .mood-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||
GridView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 2,
|
||
crossAxisSpacing: 8,
|
||
mainAxisSpacing: 8,
|
||
childAspectRatio: 1.85,
|
||
),
|
||
itemCount: _moods.length,
|
||
itemBuilder: (context, index) => _buildMoodCard(index),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildMoodCard(int index) {
|
||
final mood = _moods[index];
|
||
final isActive = _selectedMoodIndex == index;
|
||
final themeColor = Color(mood['color'] as int);
|
||
final isMystery = index == 4; // 盲盒惊喜
|
||
final isCustom = index == 5; // 自由创作
|
||
|
||
// ── Card background color logic ──
|
||
Color cardColor;
|
||
if (isCustom) {
|
||
// 自由创作: white glass morphism
|
||
cardColor = isActive
|
||
? Colors.white
|
||
: Colors.white.withOpacity(0.65);
|
||
} else if (isMystery) {
|
||
// 盲盒惊喜: richer purple tint, more eye-catching
|
||
cardColor = isActive
|
||
? Color.lerp(Colors.white, themeColor, 0.40)!
|
||
: Color.lerp(Colors.white.withOpacity(0.50), themeColor, 0.30)!;
|
||
} else {
|
||
// Normal mood cards: themed tint
|
||
cardColor = isActive
|
||
? Color.lerp(Colors.white, themeColor, 0.30)!
|
||
: Color.lerp(Colors.white.withOpacity(0.55), themeColor, 0.20)!;
|
||
}
|
||
|
||
// ── Border color logic ──
|
||
Color borderColor;
|
||
if (isCustom) {
|
||
borderColor = isActive
|
||
? const Color(0xFFECCFA8)
|
||
: Colors.white.withOpacity(0.4);
|
||
} else {
|
||
borderColor = isActive
|
||
? themeColor.withOpacity(0.55)
|
||
: themeColor.withOpacity(0.18);
|
||
}
|
||
|
||
final cardBody = AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: const Cubic(0.25, 0.46, 0.45, 0.94),
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: cardColor,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(
|
||
color: borderColor,
|
||
width: isActive ? 1.5 : 1,
|
||
),
|
||
boxShadow: isActive
|
||
? [
|
||
BoxShadow(
|
||
color: (isCustom ? const Color(0xFFECCFA8) : themeColor)
|
||
.withOpacity(0.30),
|
||
offset: const Offset(0, 6),
|
||
blurRadius: 18,
|
||
spreadRadius: -4,
|
||
),
|
||
]
|
||
: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.03),
|
||
offset: const Offset(0, 2),
|
||
blurRadius: 8,
|
||
spreadRadius: -1,
|
||
),
|
||
],
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
mood['icon'] as IconData,
|
||
size: 24,
|
||
color: isActive
|
||
? (isCustom ? const Color(0xFFECCFA8) : themeColor)
|
||
: themeColor,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
mood['title'] as String,
|
||
style: GoogleFonts.outfit(
|
||
fontSize: 14,
|
||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
||
color: isActive
|
||
? const Color(0xFF1F2937)
|
||
: const Color(0xFF374151),
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
mood['desc'] as String,
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 11,
|
||
color: isActive
|
||
? const Color(0xFF6B7280)
|
||
: const Color(0xFF9CA3AF),
|
||
height: 1.3,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
return GestureDetector(
|
||
onTap: () => _selectMood(index),
|
||
child: Stack(
|
||
children: [
|
||
// Main card body
|
||
cardBody,
|
||
// ── Mystery box: soft diagonal gleam ──
|
||
if (isMystery)
|
||
Positioned.fill(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: IgnorePointer(
|
||
child: AnimatedBuilder(
|
||
animation: _mysteryShimmerController,
|
||
builder: (context, _) {
|
||
final t = _mysteryShimmerController.value;
|
||
// Wide, soft sweep — barely visible glow
|
||
final sweep = -2.0 + t * 5.0;
|
||
return Opacity(
|
||
opacity: 0.35,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment(sweep - 1.2, sweep - 1.2),
|
||
end: Alignment(sweep + 1.2, sweep + 1.2),
|
||
colors: [
|
||
Colors.white.withOpacity(0.0),
|
||
Colors.white.withOpacity(0.08),
|
||
Colors.white.withOpacity(0.18),
|
||
Colors.white.withOpacity(0.08),
|
||
Colors.white.withOpacity(0.0),
|
||
],
|
||
stops: const [0.0, 0.25, 0.5, 0.75, 1.0],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Active indicator dot — top-right
|
||
if (isActive)
|
||
Positioned(
|
||
top: 8,
|
||
right: 8,
|
||
child: Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: isCustom ? const Color(0xFFECCFA8) : themeColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// MODALS
|
||
// ══════════════════════════════════════════════════════════════
|
||
|
||
// ── Custom Input Modal ──
|
||
void _showInputModal() {
|
||
final controller = TextEditingController();
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (ctx) => _InputModalContent(
|
||
controller: controller,
|
||
onSubmit: (text) {
|
||
Navigator.pop(ctx);
|
||
setState(() => _selectedMoodIndex = 5);
|
||
_generateMusic(text: text, mood: 'custom');
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Playlist Modal ──
|
||
void _showPlaylistModal() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (ctx) => _PlaylistModalContent(
|
||
tracks: _playlist,
|
||
currentIndex: _currentTrackIndex,
|
||
isPlaying: _isPlaying,
|
||
onSelect: (index) {
|
||
Navigator.pop(ctx);
|
||
_playTrack(index);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Confirm Dialog (new song ready) ──
|
||
void _showConfirmDialog(String songTitle) {
|
||
showGeneralDialog(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
barrierLabel: 'Dismiss',
|
||
barrierColor: Colors.black.withOpacity(0.4),
|
||
transitionDuration: const Duration(milliseconds: 300),
|
||
pageBuilder: (ctx, anim1, anim2) {
|
||
return GlassDialog(
|
||
title: '新歌已生成',
|
||
description: '是否立即试听?',
|
||
cancelText: '稍后再听',
|
||
confirmText: '立即试听',
|
||
onCancel: () {
|
||
Navigator.of(ctx).pop();
|
||
_showSpeech('已加入唱片架,随时可以听');
|
||
},
|
||
onConfirm: () {
|
||
Navigator.of(ctx).pop();
|
||
_playTrack(0);
|
||
},
|
||
);
|
||
},
|
||
transitionBuilder: (ctx, anim1, anim2, child) {
|
||
return ScaleTransition(
|
||
scale: Tween<double>(begin: 0.9, end: 1.0).animate(
|
||
CurvedAnimation(
|
||
parent: anim1,
|
||
curve: const Cubic(0.175, 0.885, 0.32, 1.275),
|
||
),
|
||
),
|
||
child: FadeTransition(opacity: anim1, child: child),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// CUSTOM PAINTERS
|
||
// ══════════════════════════════════════════════════════════════
|
||
|
||
/// Vinyl disc grooves + conic shine
|
||
/// HTML: repeating-radial-gradient(#18181B 0, #18181B 3px, #27272A 4px)
|
||
/// + conic-gradient shine overlay
|
||
// ── Bubble Tail Painter (iMessage-style small triangle) ──
|
||
class _BubbleTailPainter extends CustomPainter {
|
||
final Color color;
|
||
_BubbleTailPainter({required this.color});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final path = Path()
|
||
..moveTo(0, 0) // top-left (connects to bubble)
|
||
..lineTo(size.width, 0) // top-right
|
||
..lineTo(2, size.height) // bottom point (tail tip)
|
||
..close();
|
||
canvas.drawPath(path, Paint()..color = color);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_BubbleTailPainter old) => old.color != color;
|
||
}
|
||
|
||
// ── Circular Generation Progress Ring (matches HTML .gen-ring) ──
|
||
class _GenProgressRingPainter extends CustomPainter {
|
||
final double progress; // 0.0 ~ 1.0
|
||
|
||
_GenProgressRingPainter({required this.progress});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = Offset(size.width / 2, size.height / 2);
|
||
final radius = 108.0; // HTML: SVG viewBox 224, circle r=108
|
||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||
final sweepAngle = 2 * pi * progress;
|
||
|
||
// Track (background ring)
|
||
final trackPaint = Paint()
|
||
..color = Colors.white.withOpacity(0.12)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 3;
|
||
canvas.drawCircle(center, radius, trackPaint);
|
||
|
||
if (progress < 0.001) return;
|
||
|
||
// Layer 1: Wide soft outer glow (blurred) — creates the warm halo
|
||
final outerGlow = Paint()
|
||
..color = const Color(0xFFECCFA8).withOpacity(0.12)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 16
|
||
..strokeCap = StrokeCap.round
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
|
||
canvas.drawArc(rect, -pi / 2, sweepAngle, false, outerGlow);
|
||
|
||
// Layer 2: Medium glow — HTML: stroke-width 8, rgba(236,207,168,0.15)
|
||
final midGlow = Paint()
|
||
..color = const Color(0xFFECCFA8).withOpacity(0.20)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 8
|
||
..strokeCap = StrokeCap.round
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
|
||
canvas.drawArc(rect, -pi / 2, sweepAngle, false, midGlow);
|
||
|
||
// Layer 3: Core bar — HTML: stroke-width 3, drop-shadow(0 0 4px)
|
||
// Draw shadow pass first
|
||
final barShadow = Paint()
|
||
..color = const Color(0xFFECCFA8).withOpacity(0.50)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 4
|
||
..strokeCap = StrokeCap.round
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
|
||
canvas.drawArc(rect, -pi / 2, sweepAngle, false, barShadow);
|
||
|
||
// Core bar with gradient
|
||
final barPaint = Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 3
|
||
..strokeCap = StrokeCap.round
|
||
..shader = SweepGradient(
|
||
startAngle: -pi / 2,
|
||
endAngle: -pi / 2 + sweepAngle,
|
||
colors: const [
|
||
Color(0xFFECCFA8),
|
||
Color(0xFFD4A76A),
|
||
Color(0xFFECCFA8),
|
||
],
|
||
stops: const [0.0, 0.5, 1.0],
|
||
).createShader(rect);
|
||
canvas.drawArc(rect, -pi / 2, sweepAngle, false, barPaint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_GenProgressRingPainter old) => old.progress != progress;
|
||
}
|
||
|
||
class _VinylDiscPainter extends CustomPainter {
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = Offset(size.width / 2, size.height / 2);
|
||
|
||
// Groove rings
|
||
final groovePaint = Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 0.5;
|
||
|
||
for (double r = 20; r < size.width / 2; r += 4) {
|
||
groovePaint.color = r % 8 == 0
|
||
? Colors.white.withOpacity(0.06)
|
||
: Colors.white.withOpacity(0.03);
|
||
canvas.drawCircle(center, r, groovePaint);
|
||
}
|
||
|
||
// Conic shine (subtle light reflection)
|
||
// HTML: conic-gradient from 30deg with subtle white bands
|
||
final shinePaint = Paint()
|
||
..shader = SweepGradient(
|
||
startAngle: 30 * pi / 180,
|
||
endAngle: 30 * pi / 180 + 2 * pi,
|
||
colors: const [
|
||
Color(0x00FFFFFF), // transparent
|
||
Color(0x0DFFFFFF), // 0.05
|
||
Color(0x1CFFFFFF), // 0.11
|
||
Color(0x0DFFFFFF), // 0.05
|
||
Color(0x00FFFFFF), // transparent
|
||
Color(0x00FFFFFF),
|
||
Color(0x0DFFFFFF),
|
||
Color(0x1CFFFFFF),
|
||
Color(0x0DFFFFFF),
|
||
Color(0x00FFFFFF),
|
||
],
|
||
stops: const [
|
||
0.0, 0.033, 0.069, 0.106, 0.139,
|
||
0.5, 0.533, 0.569, 0.606, 0.639,
|
||
],
|
||
).createShader(Rect.fromCircle(center: center, radius: size.width / 2));
|
||
|
||
canvas.drawCircle(center, size.width / 2, shinePaint);
|
||
|
||
// Center hole
|
||
canvas.drawCircle(
|
||
center,
|
||
4,
|
||
Paint()..color = const Color(0xFF27272A),
|
||
);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|
||
|
||
/// Vinyl back grooves (subtler pattern)
|
||
class _VinylBackGroovesPainter extends CustomPainter {
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = Offset(size.width / 2, size.height / 2);
|
||
final groovePaint = Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 0.3
|
||
..color = const Color(0xFF1F1F23).withOpacity(0.4);
|
||
|
||
for (double r = 10; r < size.width / 2 - 3; r += 3) {
|
||
canvas.drawCircle(center, r, groovePaint);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|
||
|
||
/// Tonearm — matches HTML structure
|
||
/// HTML: .tonearm-base (18x18 radial gradient circle)
|
||
/// .tonearm-arm (3px wide, 70px tall, rotated 25deg)
|
||
/// .tonearm-head (9x10 rectangle at end of arm)
|
||
class _TonearmPainter extends CustomPainter {
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
// Base knob — HTML: .tonearm-base { top: 4px; right: 8px; width: 18px; height: 18px;
|
||
// background: radial-gradient(circle at 40% 40%, #D0D0D0, #909090); }
|
||
final baseCenter = Offset(size.width - 17, 13);
|
||
final basePaint = Paint()
|
||
..shader = RadialGradient(
|
||
center: const Alignment(-0.2, -0.2), // at 40% 40%
|
||
colors: const [Color(0xFFD0D0D0), Color(0xFF909090)],
|
||
).createShader(
|
||
Rect.fromCircle(center: baseCenter, radius: 9));
|
||
canvas.drawCircle(baseCenter, 9, basePaint);
|
||
|
||
// Inner knob — HTML: .tonearm-base::after { width: 6px; height: 6px;
|
||
// background: radial-gradient(circle at 40% 40%, #E8E8E8, #B0B0B0); }
|
||
final innerPaint = Paint()
|
||
..shader = RadialGradient(
|
||
center: const Alignment(-0.2, -0.2),
|
||
colors: const [Color(0xFFE8E8E8), Color(0xFFB0B0B0)],
|
||
).createShader(
|
||
Rect.fromCircle(center: baseCenter, radius: 3));
|
||
canvas.drawCircle(baseCenter, 3, innerPaint);
|
||
|
||
// Arm — HTML: .tonearm-arm { top: 12px; right: 16px; width: 3px; height: 70px;
|
||
// background: linear-gradient(180deg, #A0A0A0, #C0C0C0); transform: rotate(25deg); }
|
||
canvas.save();
|
||
canvas.translate(size.width - 17, 18);
|
||
canvas.rotate(25 * pi / 180);
|
||
|
||
final armPaint = Paint()
|
||
..shader = const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFFA0A0A0), Color(0xFFC0C0C0)],
|
||
).createShader(const Rect.fromLTWH(-1.5, 0, 3, 70))
|
||
..strokeWidth = 3
|
||
..strokeCap = StrokeCap.round;
|
||
canvas.drawLine(const Offset(0, 0), const Offset(0, 70), armPaint);
|
||
|
||
// Head — HTML: .tonearm-head { bottom: -6px; left: -3px; width: 9px; height: 10px;
|
||
// background: linear-gradient(180deg, #888, #666); }
|
||
final headPaint = Paint()
|
||
..shader = const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFF888888), Color(0xFF666666)],
|
||
).createShader(const Rect.fromLTWH(-4.5, 70, 9, 10));
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndRadius(
|
||
const Rect.fromLTWH(-4.5, 70, 9, 10),
|
||
const Radius.circular(1),
|
||
),
|
||
headPaint,
|
||
);
|
||
|
||
canvas.restore();
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// MODAL WIDGETS (Extracted as StatelessWidgets for cleanliness)
|
||
// ══════════════════════════════════════════════════════════════
|
||
|
||
/// Custom Input Modal — HTML: .input-modal-container
|
||
class _InputModalContent extends StatelessWidget {
|
||
final TextEditingController controller;
|
||
final ValueChanged<String> onSubmit;
|
||
|
||
const _InputModalContent({
|
||
required this.controller,
|
||
required this.onSubmit,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||
),
|
||
child: Container(
|
||
padding: EdgeInsets.fromLTRB(
|
||
20, 16, 20, 16 + MediaQuery.of(context).padding.bottom,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.95),
|
||
borderRadius:
|
||
const BorderRadius.vertical(top: Radius.circular(24)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.06),
|
||
offset: const Offset(0, -2),
|
||
blurRadius: 16,
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Handle bar
|
||
Container(
|
||
width: 36,
|
||
height: 4,
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFE8C9A8),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
// Header
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'自由创作',
|
||
style: GoogleFonts.outfit(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF374151),
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => Navigator.pop(context),
|
||
child: Container(
|
||
width: 28,
|
||
height: 28,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.05),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(Icons.close,
|
||
size: 16, color: Color(0xFF4B5563)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
// Subtitle hint
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(
|
||
'描述你想要的音乐氛围、场景或情绪',
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 12,
|
||
color: const Color(0xFF9CA3AF),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// Textarea - increased minLines for taller input
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(minHeight: 100),
|
||
child: TextField(
|
||
controller: controller,
|
||
minLines: 4,
|
||
maxLines: 6,
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 14, color: const Color(0xFF374151)),
|
||
decoration: InputDecoration(
|
||
hintText: '例如:水豚在雨中等公交,心情却很平静...',
|
||
hintStyle: GoogleFonts.dmSans(
|
||
fontSize: 14, color: const Color(0xFF9CA3AF)),
|
||
filled: true,
|
||
fillColor: Colors.black.withOpacity(0.03),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide:
|
||
BorderSide(color: Colors.black.withOpacity(0.06)),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide:
|
||
BorderSide(color: Colors.black.withOpacity(0.06)),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFFECCFA8),
|
||
width: 1.5,
|
||
),
|
||
),
|
||
contentPadding: const EdgeInsets.all(16),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// Submit button
|
||
GradientButton(
|
||
text: '生成音乐 🎵',
|
||
height: 48,
|
||
gradient: appclr.AppColors.btnPlushGradient,
|
||
onPressed: () {
|
||
final text = controller.text.trim();
|
||
if (text.isEmpty) {
|
||
AppToast.show(context, '请输入一点灵感吧 ✨', isError: true);
|
||
return;
|
||
}
|
||
onSubmit(text);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Playlist Modal — HTML: .playlist-container
|
||
class _PlaylistModalContent extends StatefulWidget {
|
||
final List<_Track> tracks;
|
||
final int currentIndex;
|
||
final bool isPlaying;
|
||
final ValueChanged<int> onSelect;
|
||
|
||
const _PlaylistModalContent({
|
||
required this.tracks,
|
||
required this.currentIndex,
|
||
required this.isPlaying,
|
||
required this.onSelect,
|
||
});
|
||
|
||
@override
|
||
State<_PlaylistModalContent> createState() => _PlaylistModalContentState();
|
||
}
|
||
|
||
class _PlaylistModalContentState extends State<_PlaylistModalContent>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _waveController;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_waveController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 800),
|
||
);
|
||
if (widget.isPlaying) _waveController.repeat(reverse: true);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _PlaylistModalContent oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (widget.isPlaying && !_waveController.isAnimating) {
|
||
_waveController.repeat(reverse: true);
|
||
} else if (!widget.isPlaying && _waveController.isAnimating) {
|
||
_waveController.stop();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_waveController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final screenWidth = MediaQuery.of(context).size.width;
|
||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||
|
||
// ── Calculate grid height for 3.5 visible rows ──
|
||
// Grid area width = screen - left(20) - right(20)
|
||
const double hPad = 20;
|
||
const double gap = 8;
|
||
const double aspectRatio = 0.75; // childAspectRatio
|
||
const double visibleRows = 3.5;
|
||
final gridWidth = screenWidth - hPad * 2;
|
||
final colWidth = (gridWidth - gap * 2) / 3; // 3 columns, 2 gaps
|
||
final cellHeight = colWidth / aspectRatio;
|
||
final rowHeight = cellHeight + gap; // cell + mainAxisSpacing
|
||
final gridMaxHeight = rowHeight * visibleRows;
|
||
|
||
// Header: ~28px row + 16px spacing = 44px
|
||
const headerHeight = 44.0;
|
||
final totalMaxHeight = headerHeight + gridMaxHeight + 24 + bottomPadding;
|
||
|
||
return Container(
|
||
constraints: BoxConstraints(
|
||
maxHeight: totalMaxHeight,
|
||
),
|
||
padding: EdgeInsets.fromLTRB(
|
||
hPad, 16, hPad, 24 + bottomPadding,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withOpacity(0.95),
|
||
borderRadius:
|
||
const BorderRadius.vertical(top: Radius.circular(24)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.06),
|
||
offset: const Offset(0, -2),
|
||
blurRadius: 16,
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Header
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'我的唱片架',
|
||
style: GoogleFonts.outfit(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF374151),
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => Navigator.pop(context),
|
||
child: Container(
|
||
width: 28,
|
||
height: 28,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.05),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(Icons.close,
|
||
size: 16, color: Color(0xFF4B5563)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Record grid — shows 3.5 rows, scroll to see more
|
||
Flexible(
|
||
child: GridView.builder(
|
||
shrinkWrap: true,
|
||
gridDelegate:
|
||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 3,
|
||
crossAxisSpacing: 8,
|
||
mainAxisSpacing: 8,
|
||
childAspectRatio: 0.75,
|
||
),
|
||
itemCount: widget.tracks.length,
|
||
itemBuilder: (context, index) {
|
||
final track = widget.tracks[index];
|
||
final isCurrent = index == widget.currentIndex;
|
||
final isPlaying = isCurrent && widget.isPlaying;
|
||
|
||
// HTML: .record-slot { background: rgba(0,0,0,0.03); border-radius: 12px;
|
||
// padding: 10px 4px; border: 1px solid rgba(0,0,0,0.02); }
|
||
return GestureDetector(
|
||
onTap: () => widget.onSelect(index),
|
||
child: Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 4, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
// Current track: warm golden background; others: subtle grey
|
||
color: isCurrent
|
||
? const Color(0xFFFDF3E3)
|
||
: Colors.black.withOpacity(0.03),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: isCurrent
|
||
? const Color(0xFFECCFA8).withOpacity(0.6)
|
||
: Colors.black.withOpacity(0.02),
|
||
width: isCurrent ? 1.5 : 1.0),
|
||
boxShadow: isCurrent
|
||
? [
|
||
BoxShadow(
|
||
color: const Color(0xFFECCFA8).withOpacity(0.25),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
]
|
||
: null,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// Mini vinyl cover
|
||
Expanded(
|
||
child: AspectRatio(
|
||
aspectRatio: 1,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: const Color(0xFF18181B),
|
||
boxShadow: [
|
||
if (isCurrent)
|
||
const BoxShadow(
|
||
color: Color(0xFFECCFA8),
|
||
spreadRadius: 2,
|
||
),
|
||
BoxShadow(
|
||
color:
|
||
Colors.black.withOpacity(0.15),
|
||
offset: const Offset(0, 8),
|
||
blurRadius: 16,
|
||
),
|
||
],
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
// Groove pattern
|
||
CustomPaint(
|
||
painter:
|
||
_VinylBackGroovesPainter(),
|
||
size: Size.infinite,
|
||
),
|
||
// Inner cover image
|
||
Center(
|
||
child: FractionallySizedBox(
|
||
widthFactor: 0.55,
|
||
heightFactor: 0.55,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: Colors.white
|
||
.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
clipBehavior:
|
||
Clip.antiAlias,
|
||
child: Image.asset(
|
||
'assets/www/Capybara.png',
|
||
fit: BoxFit.cover,
|
||
errorBuilder:
|
||
(_, __, ___) =>
|
||
const Icon(
|
||
Icons.music_note,
|
||
color: Colors.white54,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Sound wave overlay for playing track
|
||
if (isPlaying)
|
||
Center(
|
||
child: AnimatedBuilder(
|
||
animation: _waveController,
|
||
builder: (context, child) {
|
||
return CustomPaint(
|
||
painter: _MiniWavePainter(
|
||
progress: _waveController.value,
|
||
),
|
||
size: const Size(28, 20),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
// Title with playing indicator
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (isCurrent)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 3),
|
||
child: Icon(
|
||
isPlaying ? Icons.volume_up_rounded : Icons.volume_off_rounded,
|
||
size: 12,
|
||
color: const Color(0xFFECCFA8),
|
||
),
|
||
),
|
||
Flexible(
|
||
child: Text(
|
||
track.title,
|
||
style: GoogleFonts.dmSans(
|
||
fontSize: 12,
|
||
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w500,
|
||
color: isCurrent
|
||
? const Color(0xFFB8860B)
|
||
: const Color(0xFF374151),
|
||
),
|
||
textAlign: TextAlign.center,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Mini sound wave painter for playlist playing indicator
|
||
class _MiniWavePainter extends CustomPainter {
|
||
final double progress;
|
||
|
||
_MiniWavePainter({required this.progress});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final paint = Paint()
|
||
..color = const Color(0xFFECCFA8)
|
||
..strokeWidth = 2.5
|
||
..strokeCap = StrokeCap.round;
|
||
|
||
const barCount = 4;
|
||
final barWidth = size.width / (barCount * 2 - 1);
|
||
final centerY = size.height / 2;
|
||
|
||
for (int i = 0; i < barCount; i++) {
|
||
// Each bar has a different phase offset for wave effect
|
||
final phase = (progress + i * 0.25) % 1.0;
|
||
final height = size.height * (0.3 + 0.7 * (0.5 + 0.5 * sin(phase * 3.14159 * 2)));
|
||
final x = i * barWidth * 2 + barWidth / 2;
|
||
|
||
canvas.drawLine(
|
||
Offset(x, centerY - height / 2),
|
||
Offset(x, centerY + height / 2),
|
||
paint,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _MiniWavePainter oldDelegate) =>
|
||
oldDelegate.progress != progress;
|
||
}
|
||
|