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 createState() => _MusicCreationPageState(); } class _MusicCreationPageState extends State 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 _tonearmAngle; late AnimationController _flipController; late Animation _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> _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(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(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 _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 _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(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 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 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; }