fromis_9/app/lib/views/album/track_detail_view.dart

669 lines
22 KiB
Dart
Raw Normal View History

/// 트랙 상세 화면
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<TrackDetailView> createState() => _TrackDetailViewState();
}
class _TrackDetailViewState extends State<TrackDetailView> {
late Future<TrackDetail> _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<String> _parseCredit(String? text) {
if (text == null || text.isEmpty) return [];
return text.split(',').map((s) => s.trim()).toList();
}
/// YouTube 앱 또는 브라우저로 열기
Future<void> _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<TrackDetail>(
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<String> lyricist;
final List<String> composer;
final List<String> 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<String> 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),
],
),
),
],
),
);
}
}