rtc_prd/airhub_app/lib/pages/music_creation_page.dart
seaislee1209 8f5fb32b37 feat(story,music,server): 豆包故事生成 + 历史数据持久化 + 封面占位
- 接入火山引擎豆包 Chat API 生成儿童故事(SSE 流式进度)
- 新增 /api/stories 接口加载历史故事到书架
- 新增 /api/playlist 接口加载历史歌曲到唱片架
- 书架排序:预设故事在前,AI 生成在后
- AI 生成的故事显示"暂无封面"淡紫渐变占位
- 保存故事时传回真实标题+内容(不再用 mock)
- 修复 Windows GBK 编码导致的中文乱码问题
- 新增 MusicGenerationService 单例管理音乐生成
- 音乐页心情卡片 UI 重做 + 歌词可读性优化
- 添加豆包 API 参考文档和故事创作 prompt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:11:58 +08:00

2130 lines
73 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
await _audioPlayer.play();
} else {
await _audioPlayer.pause();
}
} catch (e) {
debugPrint('Playback error: $e');
if (mounted) {
_showSpeech('播放出错了,请重试');
// Revert UI state on error
setState(() {
_isPlaying = false;
_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');
}
}
_showSpeech('正在播放: ${_playlist[index].title}');
}
// ── 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
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 {
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
if (_speechVisible && _speechText != 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;
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _speechVisible ? 1.0 : 0.0,
child: AnimatedScale(
duration: const Duration(milliseconds: 350),
scale: _speechVisible ? 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(
_speechText ?? '',
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,
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 StatelessWidget {
final List<_Track> tracks;
final int currentIndex;
final ValueChanged<int> onSelect;
const _PlaylistModalContent({
required this.tracks,
required this.currentIndex,
required this.onSelect,
});
@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: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
final isPlaying = index == currentIndex;
// 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: () => onSelect(index),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 10),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.03),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.black.withOpacity(0.02)),
),
child: Column(
children: [
// Mini vinyl cover
Expanded(
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF18181B),
// HTML: .record-item.playing .record-cover-wrapper
// { box-shadow: 0 0 0 2px #ECCFA8, ... }
boxShadow: [
if (isPlaying)
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,
),
),
),
),
),
],
),
),
),
),
const SizedBox(height: 8),
// HTML: .record-title { font-size: 12px; font-weight: 500; }
Text(
track.title,
style: GoogleFonts.dmSans(
fontSize: 12,
fontWeight: FontWeight.w500,
color: const Color(0xFF374151),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
),
],
),
);
}
}