- DB: album_teasers 테이블에 video_url 컬럼 추가 - 백엔드: 비디오 업로드 시 ffmpeg로 썸네일 추출 후 WebP 저장 - 백엔드: video_url에 MP4 URL 저장, 썸네일은 기존 URL 필드 사용 - 프론트엔드: 썸네일 이미지 표시, 클릭 시 video_url로 재생 - Flutter 앱: Teaser 모델에 videoUrl 필드 추가 및 비디오 재생 수정 - Docker: ffmpeg 설치 추가 (Dockerfile, docker-compose.dev.yml) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
668 lines
22 KiB
Dart
668 lines
22 KiB
Dart
/// 트랙 상세 화면
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|