/// 트랙 상세 화면 library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_service.dart'; class TrackDetailView extends StatefulWidget { final String albumName; final String trackTitle; const TrackDetailView({ super.key, required this.albumName, required this.trackTitle, }); @override State createState() => _TrackDetailViewState(); } class _TrackDetailViewState extends State { late Future _trackFuture; bool _showFullLyrics = false; @override void initState() { super.initState(); _trackFuture = getTrack(widget.albumName, widget.trackTitle); } /// YouTube URL에서 비디오 ID 추출 String? _getYoutubeVideoId(String? url) { if (url == null) return null; final regex = RegExp( r'(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)', ); final match = regex.firstMatch(url); return match?.group(1); } /// 쉼표로 구분된 크레딧을 리스트로 변환 List _parseCredit(String? text) { if (text == null || text.isEmpty) return []; return text.split(',').map((s) => s.trim()).toList(); } /// YouTube 앱 또는 브라우저로 열기 Future _openYoutube(String videoId) async { final url = Uri.parse('https://www.youtube.com/watch?v=$videoId'); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: FutureBuilder( future: _trackFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (snapshot.hasError || !snapshot.hasData) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(LucideIcons.alertCircle, size: 48, color: AppColors.textTertiary), const SizedBox(height: 16), const Text('트랙을 찾을 수 없습니다'), const SizedBox(height: 16), TextButton( onPressed: () => context.pop(), child: const Text('뒤로 가기'), ), ], ), ); } final track = snapshot.data!; final youtubeVideoId = _getYoutubeVideoId(track.musicVideoUrl); return CustomScrollView( slivers: [ // 앱바 SliverAppBar( pinned: true, backgroundColor: Colors.white, foregroundColor: AppColors.textPrimary, elevation: 0, leading: IconButton( icon: const Icon(LucideIcons.arrowLeft), onPressed: () => context.pop(), ), title: const Text( '트랙', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), ), // 트랙 정보 헤더 SliverToBoxAdapter( child: _TrackHeader(track: track), ), // 뮤직비디오 if (youtubeVideoId != null) SliverToBoxAdapter( child: _MusicVideoSection( videoId: youtubeVideoId, trackTitle: track.title, onTap: () => _openYoutube(youtubeVideoId), ), ), // 크레딧 if (track.lyricist != null || track.composer != null || track.arranger != null) SliverToBoxAdapter( child: _CreditSection( lyricist: _parseCredit(track.lyricist), composer: _parseCredit(track.composer), arranger: _parseCredit(track.arranger), ), ), // 가사 SliverToBoxAdapter( child: _LyricsSection( lyrics: track.lyrics, showFull: _showFullLyrics, onToggle: () => setState(() => _showFullLyrics = !_showFullLyrics), ), ), // 하단 여백 SliverToBoxAdapter( child: SizedBox(height: 16 + MediaQuery.of(context).padding.bottom), ), ], ); }, ), ); } } /// 트랙 헤더 class _TrackHeader extends StatelessWidget { final TrackDetail track; const _TrackHeader({required this.track}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 앨범 커버 Container( width: 96, height: 96, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: track.album?.coverMediumUrl != null ? CachedNetworkImage( imageUrl: track.album!.coverMediumUrl!, fit: BoxFit.cover, placeholder: (context, url) => Container(color: AppColors.divider), errorWidget: (context, url, error) => Container( color: AppColors.divider, child: const Icon(LucideIcons.disc3, color: AppColors.textTertiary), ), ) : Container( color: AppColors.divider, child: const Icon(LucideIcons.disc3, color: AppColors.textTertiary), ), ), ), const SizedBox(width: 16), // 트랙 정보 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 타이틀 뱃지 & 트랙 번호 Row( children: [ if (track.isTitleTrack == 1) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(10), ), child: const Text( 'TITLE', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), const SizedBox(width: 8), ], Text( 'Track ${track.trackNumber.toString().padLeft(2, '0')}', style: const TextStyle( fontSize: 12, color: AppColors.textTertiary, ), ), ], ), const SizedBox(height: 6), // 트랙 제목 Text( track.title, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), // 앨범 정보 Text( '${track.album?.albumType ?? ''} · ${track.album?.title ?? ''}', style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), // 재생 시간 if (track.duration != null) Row( children: [ const Icon(LucideIcons.clock, size: 14, color: AppColors.textTertiary), const SizedBox(width: 4), Text( track.duration!, style: const TextStyle( fontSize: 13, color: AppColors.textTertiary, fontFeatures: [FontFeature.tabularFigures()], ), ), ], ), ], ), ), ], ), ); } } /// 뮤직비디오 섹션 class _MusicVideoSection extends StatelessWidget { final String videoId; final String trackTitle; final VoidCallback onTap; const _MusicVideoSection({ required this.videoId, required this.trackTitle, required this.onTap, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 섹션 제목 Row( children: [ Container( width: 4, height: 16, decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), const Text( '뮤직비디오', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 12), // 썸네일 GestureDetector( onTap: onTap, child: Container( width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ CachedNetworkImage( imageUrl: 'https://img.youtube.com/vi/$videoId/maxresdefault.jpg', fit: BoxFit.cover, placeholder: (context, url) => Container(color: Colors.black), errorWidget: (context, url, error) => CachedNetworkImage( imageUrl: 'https://img.youtube.com/vi/$videoId/hqdefault.jpg', fit: BoxFit.cover, ), ), // 재생 버튼 오버레이 Container( color: Colors.black.withValues(alpha: 0.3), child: Center( child: Container( width: 56, height: 56, decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(28), ), child: const Icon( LucideIcons.play, color: Colors.white, size: 28, ), ), ), ), ], ), ), ), ), ), ], ), ); } } /// 크레딧 섹션 class _CreditSection extends StatelessWidget { final List lyricist; final List composer; final List arranger; const _CreditSection({ required this.lyricist, required this.composer, required this.arranger, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 섹션 제목 Row( children: [ Container( width: 4, height: 16, decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), const Text( '크레딧', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 12), // 크레딧 카드 Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.divider.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), ), child: Column( children: [ if (lyricist.isNotEmpty) _CreditItem( icon: LucideIcons.mic2, label: '작사', credits: lyricist, ), if (composer.isNotEmpty) ...[ if (lyricist.isNotEmpty) const SizedBox(height: 16), _CreditItem( icon: LucideIcons.music, label: '작곡', credits: composer, ), ], if (arranger.isNotEmpty) ...[ if (lyricist.isNotEmpty || composer.isNotEmpty) const SizedBox(height: 16), _CreditItem( icon: LucideIcons.user, label: '편곡', credits: arranger, ), ], ], ), ), ], ), ); } } /// 크레딧 아이템 class _CreditItem extends StatelessWidget { final IconData icon; final String label; final List credits; const _CreditItem({ required this.icon, required this.label, required this.credits, }); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 4, ), ], ), child: Icon(icon, size: 14, color: AppColors.textSecondary), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( fontSize: 11, color: AppColors.textTertiary, ), ), const SizedBox(height: 2), ...credits.map((credit) => Text( credit, style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.5, ), )), ], ), ), ], ); } } /// 가사 섹션 class _LyricsSection extends StatelessWidget { final String? lyrics; final bool showFull; final VoidCallback onToggle; const _LyricsSection({ required this.lyrics, required this.showFull, required this.onToggle, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 섹션 제목 Row( children: [ Container( width: 4, height: 16, decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), const Text( '가사', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 12), // 가사 카드 Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.divider.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), ), child: lyrics != null && lyrics!.isNotEmpty ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AnimatedCrossFade( firstChild: Text( lyrics!, style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.8, ), textAlign: TextAlign.left, maxLines: 6, overflow: TextOverflow.ellipsis, ), secondChild: Text( lyrics!, style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, height: 1.8, ), textAlign: TextAlign.left, ), crossFadeState: showFull ? CrossFadeState.showSecond : CrossFadeState.showFirst, duration: const Duration(milliseconds: 200), ), const SizedBox(height: 12), GestureDetector( onTap: onToggle, child: Container( padding: const EdgeInsets.only(top: 12), decoration: const BoxDecoration( border: Border( top: BorderSide(color: AppColors.divider), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( showFull ? '접기' : '더보기', style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), Icon( showFull ? LucideIcons.chevronUp : LucideIcons.chevronDown, size: 16, color: AppColors.textSecondary, ), ], ), ), ), ], ) : Column( children: [ const SizedBox(height: 16), Icon( LucideIcons.mic2, size: 36, color: AppColors.textTertiary.withValues(alpha: 0.3), ), const SizedBox(height: 8), const Text( '가사 정보가 없습니다', style: TextStyle( fontSize: 13, color: AppColors.textTertiary, ), ), const SizedBox(height: 16), ], ), ), ], ), ); } }