diff --git a/Dockerfile b/Dockerfile index 49726a2..fb9c2b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,9 @@ RUN npm run build FROM node:20-alpine WORKDIR /app +# ffmpeg 설치 (비디오 썸네일 추출용) +RUN apk add --no-cache ffmpeg + # 백엔드 의존성 설치 COPY backend/package*.json ./ RUN npm install --production diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart index a52dd30..4b8ac4c 100644 --- a/app/lib/core/router.dart +++ b/app/lib/core/router.dart @@ -8,6 +8,7 @@ import '../views/home/home_view.dart'; import '../views/members/members_view.dart'; import '../views/album/album_view.dart'; import '../views/album/album_detail_view.dart'; +import '../views/album/track_detail_view.dart'; import '../views/schedule/schedule_view.dart'; /// 네비게이션 키 @@ -57,5 +58,18 @@ final GoRouter appRouter = GoRouter( return AlbumDetailView(albumName: albumName); }, ), + // 트랙 상세 (셸 외부) + GoRoute( + path: '/album/:albumName/track/:trackTitle', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) { + final albumName = state.pathParameters['albumName']!; + final trackTitle = state.pathParameters['trackTitle']!; + return TrackDetailView( + albumName: albumName, + trackTitle: trackTitle, + ); + }, + ), ], ); diff --git a/app/lib/models/album.dart b/app/lib/models/album.dart index bfc7c20..215e16b 100644 --- a/app/lib/models/album.dart +++ b/app/lib/models/album.dart @@ -136,12 +136,14 @@ class Teaser { final int id; final String? originalUrl; final String? thumbUrl; + final String? videoUrl; final String? mediaType; Teaser({ required this.id, this.originalUrl, this.thumbUrl, + this.videoUrl, this.mediaType, }); @@ -150,6 +152,7 @@ class Teaser { id: (json['id'] as num?)?.toInt() ?? 0, originalUrl: json['original_url'] as String?, thumbUrl: json['thumb_url'] as String?, + videoUrl: json['video_url'] as String?, mediaType: json['media_type'] as String?, ); } @@ -178,3 +181,75 @@ class ConceptPhoto { ); } } + +/// 트랙 상세 모델 (앨범 정보 포함) +class TrackDetail { + final int id; + final int trackNumber; + final String title; + final String? duration; + final int isTitleTrack; + final String? lyricist; + final String? composer; + final String? arranger; + final String? lyrics; + final String? musicVideoUrl; + final TrackAlbum? album; + + TrackDetail({ + required this.id, + required this.trackNumber, + required this.title, + this.duration, + this.isTitleTrack = 0, + this.lyricist, + this.composer, + this.arranger, + this.lyrics, + this.musicVideoUrl, + this.album, + }); + + factory TrackDetail.fromJson(Map json) { + return TrackDetail( + id: (json['id'] as num?)?.toInt() ?? 0, + trackNumber: (json['track_number'] as num?)?.toInt() ?? 0, + title: json['title'] as String? ?? '', + duration: json['duration'] as String?, + isTitleTrack: (json['is_title_track'] as num?)?.toInt() ?? 0, + lyricist: json['lyricist'] as String?, + composer: json['composer'] as String?, + arranger: json['arranger'] as String?, + lyrics: json['lyrics'] as String?, + musicVideoUrl: json['music_video_url'] as String?, + album: json['album'] != null ? TrackAlbum.fromJson(json['album']) : null, + ); + } +} + +/// 트랙에 포함된 앨범 정보 +class TrackAlbum { + final int id; + final String title; + final String? albumType; + final String? coverMediumUrl; + final String? folderName; + + TrackAlbum({ + required this.id, + required this.title, + this.albumType, + this.coverMediumUrl, + this.folderName, + }); + + factory TrackAlbum.fromJson(Map json) { + return TrackAlbum( + id: (json['id'] as num?)?.toInt() ?? 0, + title: json['title'] as String? ?? '', + albumType: json['album_type'] as String?, + coverMediumUrl: json['cover_medium_url'] as String?, + folderName: json['folder_name'] as String?, + ); + } +} diff --git a/app/lib/services/albums_service.dart b/app/lib/services/albums_service.dart index 51ebf48..93bd546 100644 --- a/app/lib/services/albums_service.dart +++ b/app/lib/services/albums_service.dart @@ -22,3 +22,11 @@ Future getAlbumByName(String name) async { final response = await dio.get('/albums/by-name/$name'); return Album.fromJson(response.data); } + +/// 트랙 상세 조회 (앨범명, 트랙명으로) +Future getTrack(String albumName, String trackTitle) async { + final encodedAlbum = Uri.encodeComponent(albumName); + final encodedTrack = Uri.encodeComponent(trackTitle); + final response = await dio.get('/albums/by-name/$encodedAlbum/track/$encodedTrack'); + return TrackDetail.fromJson(response.data); +} diff --git a/app/lib/views/album/album_detail_view.dart b/app/lib/views/album/album_detail_view.dart index d73edf3..a56aa1a 100644 --- a/app/lib/views/album/album_detail_view.dart +++ b/app/lib/views/album/album_detail_view.dart @@ -1,6 +1,7 @@ /// 앨범 상세 화면 library; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +11,10 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:video_player/video_player.dart'; +import 'package:chewie/chewie.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; +import 'package:path_provider/path_provider.dart'; import '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_service.dart'; @@ -156,6 +161,7 @@ class _AlbumDetailViewState extends State { SliverToBoxAdapter( child: _TracksSection( album: album, + albumName: widget.albumName, displayTracks: displayTracks, showAllTracks: _showAllTracks, onToggle: () => setState(() => _showAllTracks = !_showAllTracks), @@ -444,46 +450,7 @@ class _TeaserSection extends StatelessWidget { padding: EdgeInsets.only(right: index < teasers.length - 1 ? 12 : 0), child: GestureDetector( onTap: () => _showImageViewer(context, teasers, index), - child: Container( - width: 96, - height: 96, - decoration: BoxDecoration( - color: AppColors.divider, - borderRadius: BorderRadius.circular(16), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - fit: StackFit.expand, - children: [ - if (teaser.thumbUrl != null || teaser.originalUrl != null) - CachedNetworkImage( - imageUrl: teaser.thumbUrl ?? teaser.originalUrl!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: AppColors.divider, - ), - errorWidget: (context, url, error) => const SizedBox(), - ), - if (teaser.mediaType == 'video') - Container( - color: Colors.black.withValues(alpha: 0.3), - child: const Center( - child: CircleAvatar( - radius: 16, - backgroundColor: Colors.white, - child: Icon( - LucideIcons.play, - size: 18, - color: AppColors.textPrimary, - ), - ), - ), - ), - ], - ), - ), - ), + child: _TeaserThumbnail(teaser: teaser), ), ); }, @@ -500,8 +467,8 @@ class _TeaserSection extends StatelessWidget { PageRouteBuilder( opaque: false, pageBuilder: (context, animation, secondaryAnimation) { - return _TeaserImageViewer( - images: teasers.map((t) => t.originalUrl ?? '').toList(), + return _TeaserViewer( + teasers: teasers, initialIndex: initialIndex, ); }, @@ -513,15 +480,180 @@ class _TeaserSection extends StatelessWidget { } } +/// 티저 썸네일 (동영상의 경우 video_thumbnail으로 1초 프레임 추출) +class _TeaserThumbnail extends StatefulWidget { + final Teaser teaser; + + const _TeaserThumbnail({required this.teaser}); + + @override + State<_TeaserThumbnail> createState() => _TeaserThumbnailState(); +} + +class _TeaserThumbnailState extends State<_TeaserThumbnail> { + String? _thumbnailPath; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + if (widget.teaser.mediaType == 'video' && widget.teaser.thumbUrl == null) { + _extractThumbnail(); + } else { + _isLoading = false; + } + } + + Future _extractThumbnail() async { + if (widget.teaser.originalUrl == null) { + if (mounted) { + setState(() => _isLoading = false); + } + return; + } + + try { + // 임시 디렉토리 경로 획득 + final tempDir = await getTemporaryDirectory(); + + // 썸네일 파일 생성 + final thumbnailPath = await VideoThumbnail.thumbnailFile( + video: widget.teaser.originalUrl!, + thumbnailPath: tempDir.path, + imageFormat: ImageFormat.JPEG, + maxHeight: 200, + quality: 75, + timeMs: 1000, // 1초 위치 + ); + + if (mounted) { + setState(() { + _thumbnailPath = thumbnailPath; + _isLoading = false; + }); + } + } catch (e) { + // 썸네일 추출 실패 - 플레이스홀더 표시 + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final teaser = widget.teaser; + final isVideo = teaser.mediaType == 'video'; + + return Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + fit: StackFit.expand, + children: [ + // 이미지 또는 동영상 썸네일 + _buildThumbnail(), + // 동영상 재생 버튼 오버레이 + if (isVideo) + Container( + color: Colors.black.withValues(alpha: 0.3), + child: const Center( + child: CircleAvatar( + radius: 16, + backgroundColor: Colors.white, + child: Icon( + LucideIcons.play, + size: 18, + color: AppColors.textPrimary, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildThumbnail() { + final teaser = widget.teaser; + final isVideo = teaser.mediaType == 'video'; + + // 이미지이거나 thumbUrl이 있는 경우 + if (!isVideo || teaser.thumbUrl != null) { + final imageUrl = teaser.thumbUrl ?? teaser.originalUrl; + if (imageUrl != null) { + return CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container(color: AppColors.divider), + errorWidget: (context, url, error) => _buildPlaceholder(), + ); + } + return _buildPlaceholder(); + } + + // 동영상 썸네일 로딩 중 + if (_isLoading) { + return Container( + color: AppColors.divider, + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textTertiary, + ), + ), + ), + ); + } + + // 동영상 썸네일 추출 성공 + if (_thumbnailPath != null) { + return Image.file( + File(_thumbnailPath!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildPlaceholder(), + ); + } + + // 에러 발생 시 플레이스홀더 + return _buildPlaceholder(); + } + + Widget _buildPlaceholder() { + return Container( + color: AppColors.divider, + child: const Center( + child: Icon( + LucideIcons.video, + size: 32, + color: AppColors.textTertiary, + ), + ), + ); + } +} + /// 수록곡 섹션 class _TracksSection extends StatelessWidget { final Album album; + final String albumName; final List? displayTracks; final bool showAllTracks; final VoidCallback onToggle; const _TracksSection({ required this.album, + required this.albumName, required this.displayTracks, required this.showAllTracks, required this.onToggle, @@ -548,7 +680,10 @@ class _TracksSection extends StatelessWidget { ), ), const SizedBox(height: 12), - ...?displayTracks?.map((track) => _TrackItem(track: track)), + ...?displayTracks?.map((track) => _TrackItem( + track: track, + albumName: albumName, + )), if (album.tracks != null && album.tracks!.length > 5) GestureDetector( onTap: onToggle, @@ -584,76 +719,91 @@ class _TracksSection extends StatelessWidget { /// 트랙 아이템 class _TrackItem extends StatelessWidget { final Track track; + final String albumName; - const _TrackItem({required this.track}); + const _TrackItem({required this.track, required this.albumName}); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), - child: Row( - children: [ - // 트랙 번호 - SizedBox( - width: 24, - child: Text( - track.trackNumber.toString().padLeft(2, '0'), + return GestureDetector( + onTap: () { + final encodedTrackTitle = Uri.encodeComponent(track.title); + context.push('/album/$albumName/track/$encodedTrackTitle'); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + child: Row( + children: [ + // 트랙 번호 + SizedBox( + width: 24, + child: Text( + track.trackNumber.toString().padLeft(2, '0'), + style: const TextStyle( + fontSize: 14, + color: AppColors.textTertiary, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: 12), + // 트랙 제목 + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + track.title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: track.isTitleTrack == 1 + ? AppColors.primary + : AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (track.isTitleTrack == 1) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'TITLE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], + ), + ), + // 재생 시간 + Text( + track.duration ?? '-', style: const TextStyle( - fontSize: 14, + fontSize: 12, color: AppColors.textTertiary, fontFeatures: [FontFeature.tabularFigures()], ), ), - ), - const SizedBox(width: 12), - // 트랙 제목 - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - track.title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: track.isTitleTrack == 1 - ? AppColors.primary - : AppColors.textPrimary, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (track.isTitleTrack == 1) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'TITLE', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), - ), - // 재생 시간 - Text( - track.duration ?? '-', - style: const TextStyle( - fontSize: 12, + // 화살표 아이콘 + const SizedBox(width: 8), + const Icon( + LucideIcons.chevronRight, + size: 16, color: AppColors.textTertiary, - fontFeatures: [FontFeature.tabularFigures()], ), - ), - ], + ], + ), ), ); } @@ -777,21 +927,21 @@ class _ConceptPhotosSection extends StatelessWidget { } } -/// 티저용 이미지 뷰어 (스와이프, 인디케이터 있음) -class _TeaserImageViewer extends StatefulWidget { - final List images; +/// 티저 뷰어 (이미지 + 동영상 지원) +class _TeaserViewer extends StatefulWidget { + final List teasers; final int initialIndex; - const _TeaserImageViewer({ - required this.images, + const _TeaserViewer({ + required this.teasers, required this.initialIndex, }); @override - State<_TeaserImageViewer> createState() => _TeaserImageViewerState(); + State<_TeaserViewer> createState() => _TeaserViewerState(); } -class _TeaserImageViewerState extends State<_TeaserImageViewer> { +class _TeaserViewerState extends State<_TeaserViewer> { late PageController _pageController; late int _currentIndex; final Set _preloadedIndices = {}; @@ -813,17 +963,20 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { super.dispose(); } - /// 주변 이미지 프리로드 (좌우 2장씩) + /// 주변 이미지 프리로드 (좌우 2장씩, 이미지만) void _preloadAdjacentImages(int index) { for (int i = index - 2; i <= index + 2; i++) { - if (i >= 0 && i < widget.images.length && !_preloadedIndices.contains(i)) { - final url = widget.images[i]; - if (url.isNotEmpty) { - _preloadedIndices.add(i); - precacheImage( - CachedNetworkImageProvider(url), - context, - ); + if (i >= 0 && i < widget.teasers.length && !_preloadedIndices.contains(i)) { + final teaser = widget.teasers[i]; + if (teaser.mediaType != 'video') { + final url = teaser.originalUrl; + if (url != null && url.isNotEmpty) { + _preloadedIndices.add(i); + precacheImage( + CachedNetworkImageProvider(url), + context, + ); + } } } } @@ -831,8 +984,10 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { /// 이미지 다운로드 (시스템 다운로드 매니저 사용) Future _downloadImage() async { - final imageUrl = widget.images[_currentIndex]; - if (imageUrl.isEmpty) return; + final teaser = widget.teasers[_currentIndex]; + if (teaser.mediaType == 'video') return; // 동영상은 다운로드 안함 + final imageUrl = teaser.originalUrl; + if (imageUrl == null || imageUrl.isEmpty) return; final taskId = await downloadImage(imageUrl); if (taskId != null && mounted) { @@ -849,6 +1004,8 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; final topPadding = MediaQuery.of(context).padding.top; + final currentTeaser = widget.teasers[_currentIndex]; + final isVideo = currentTeaser.mediaType == 'video'; return AnnotatedRegion( value: SystemUiOverlayStyle.light, @@ -856,18 +1013,29 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { backgroundColor: Colors.black, body: Stack( children: [ - // 이미지 갤러리 (핀치줌 지원) + // 갤러리 PhotoViewGallery.builder( pageController: _pageController, - itemCount: widget.images.length, + itemCount: widget.teasers.length, onPageChanged: (index) { setState(() => _currentIndex = index); _preloadAdjacentImages(index); }, backgroundDecoration: const BoxDecoration(color: Colors.black), builder: (context, index) { - final imageUrl = widget.images[index]; - if (imageUrl.isEmpty) { + final teaser = widget.teasers[index]; + final isVideoItem = teaser.mediaType == 'video'; + + // 동영상인 경우 Chewie 플레이어로 재생 + if (isVideoItem) { + return PhotoViewGalleryPageOptions.customChild( + child: _VideoTeaserPage(teaser: teaser), + ); + } + + // 이미지인 경우 + final imageUrl = teaser.thumbUrl ?? teaser.originalUrl; + if (imageUrl == null || imageUrl.isEmpty) { return PhotoViewGalleryPageOptions.customChild( child: const Center( child: Icon( @@ -878,8 +1046,10 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { ), ); } + + // 이미지인 경우 PhotoView 사용 return PhotoViewGalleryPageOptions( - imageProvider: CachedNetworkImageProvider(imageUrl), + imageProvider: CachedNetworkImageProvider(teaser.originalUrl ?? imageUrl), minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3, initialScale: PhotoViewComputedScale.contained, @@ -893,63 +1063,69 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { ), ), ), - // 상단 헤더 - 3등분 - Positioned( - top: topPadding + 8, - left: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - // 왼쪽: 닫기 버튼 - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: () => Navigator.pop(context), - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon(LucideIcons.x, color: Colors.white70, size: 24), + // 상단 헤더 + Positioned( + top: topPadding + 8, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // 왼쪽: 닫기 버튼 + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(LucideIcons.x, color: Colors.white70, size: 24), + ), ), ), ), - ), - // 가운데: 페이지 번호 - if (widget.images.length > 1) - Text( - '${_currentIndex + 1} / ${widget.images.length}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - // 오른쪽: 다운로드 버튼 - Expanded( - child: Align( - alignment: Alignment.centerRight, - child: GestureDetector( - onTap: _downloadImage, - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon(LucideIcons.download, color: Colors.white70, size: 22), + // 가운데: 페이지 번호 + if (widget.teasers.length > 1) + Text( + '${_currentIndex + 1} / ${widget.teasers.length}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + fontFeatures: [FontFeature.tabularFigures()], ), ), + // 오른쪽: 다운로드 버튼 (이미지만) + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: isVideo + ? const SizedBox(width: 30) + : GestureDetector( + onTap: _downloadImage, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon( + LucideIcons.download, + color: Colors.white70, + size: 22, + ), + ), + ), + ), ), - ), - ], + ], + ), ), ), - ), // 하단 인디케이터 - if (widget.images.length > 1) + if (widget.teasers.length > 1) Positioned( bottom: bottomPadding + 16, left: 0, right: 0, child: _SlidingIndicator( - count: widget.images.length, + count: widget.teasers.length, currentIndex: _currentIndex, onTap: (index) { _pageController.animateToPage( @@ -967,6 +1143,121 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> { } } +/// 동영상 티저 페이지 (Chewie로 내부 재생) +class _VideoTeaserPage extends StatefulWidget { + final Teaser teaser; + + const _VideoTeaserPage({required this.teaser}); + + @override + State<_VideoTeaserPage> createState() => _VideoTeaserPageState(); +} + +class _VideoTeaserPageState extends State<_VideoTeaserPage> { + VideoPlayerController? _videoController; + ChewieController? _chewieController; + bool _isInitialized = false; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _initializePlayer(); + } + + Future _initializePlayer() async { + final videoUrl = widget.teaser.videoUrl ?? widget.teaser.originalUrl; + if (videoUrl == null) { + setState(() => _hasError = true); + return; + } + + try { + final videoController = VideoPlayerController.networkUrl( + Uri.parse(videoUrl), + ); + _videoController = videoController; + + await videoController.initialize(); + + final chewieController = ChewieController( + videoPlayerController: videoController, + autoPlay: false, + looping: false, + showControls: true, + allowFullScreen: false, + allowMuting: true, + showOptions: false, + placeholder: Container(color: Colors.black), + errorBuilder: (context, errorMessage) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.alertCircle, color: Colors.white54, size: 48), + const SizedBox(height: 8), + Text( + '동영상을 재생할 수 없습니다', + style: const TextStyle(color: Colors.white54), + ), + ], + ), + ), + ); + _chewieController = chewieController; + + if (mounted) { + setState(() => _isInitialized = true); + } + } catch (e) { + if (mounted) { + setState(() => _hasError = true); + } + } + } + + @override + void dispose() { + _chewieController?.dispose(); + _videoController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.alertCircle, color: Colors.white54, size: 48), + SizedBox(height: 8), + Text( + '동영상을 불러올 수 없습니다', + style: TextStyle(color: Colors.white54), + ), + ], + ), + ); + } + + if (!_isInitialized || _chewieController == null) { + return const Center( + child: CircularProgressIndicator( + color: Colors.white54, + strokeWidth: 2, + ), + ); + } + + return Center( + child: AspectRatio( + aspectRatio: _videoController!.value.aspectRatio, + child: Chewie(controller: _chewieController!), + ), + ); + } +} + /// 컨셉 포토용 이미지 뷰어 (단일 이미지, 스와이프 없음) class _SingleImageViewer extends StatelessWidget { final String imageUrl; diff --git a/app/lib/views/album/track_detail_view.dart b/app/lib/views/album/track_detail_view.dart new file mode 100644 index 0000000..daa9ad7 --- /dev/null +++ b/app/lib/views/album/track_detail_view.dart @@ -0,0 +1,668 @@ +/// 트랙 상세 화면 +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), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 368554e..0dafe0c 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,18 @@ import FlutterMacOS import Foundation +import package_info_plus import path_provider_foundation import sqflite_darwin import url_launcher_macos +import video_player_avfoundation +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/app/pubspec.lock b/app/pubspec.lock index 27fba4e..d9ce412 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + chewie: + dependency: "direct main" + description: + name: chewie + sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca" + url: "https://pub.dev" + source: hosted + version: "1.13.0" cli_config: dependency: transitive description: @@ -121,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -129,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" dio: dependency: "direct main" description: @@ -256,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "17.0.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -392,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" node_preamble: dependency: transitive description: @@ -416,6 +456,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -568,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -877,6 +941,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a + url: "https://pub.dev" + source: hosted + version: "2.9.1" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4 + url: "https://pub.dev" + source: hosted + version: "2.8.9" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + video_thumbnail: + dependency: "direct main" + description: + name: video_thumbnail + sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b" + url: "https://pub.dev" + source: hosted + version: "0.5.6" vm_service: dependency: transitive description: @@ -885,6 +997,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" watcher: dependency: transitive description: @@ -925,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9273359..103c6d7 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -47,6 +47,9 @@ dependencies: flutter_downloader: ^1.11.8 permission_handler: ^11.3.1 modal_bottom_sheet: ^3.0.0 + video_thumbnail: ^0.5.3 + video_player: ^2.9.2 + chewie: ^1.8.5 dev_dependencies: flutter_test: diff --git a/backend/package-lock.json b/backend/package-lock.json index c1ebba6..8c7d1fb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "bcrypt": "^6.0.0", "dayjs": "^1.11.19", "express": "^4.18.2", + "fluent-ffmpeg": "^2.1.3", "inko": "^1.1.1", "ioredis": "^5.4.0", "jsonwebtoken": "^9.0.3", @@ -2055,6 +2056,11 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -2554,6 +2560,20 @@ "node": ">= 0.8" } }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2849,6 +2869,12 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -3780,6 +3806,18 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 97b66ab..643e866 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "mysql2": "^3.11.0", "node-cron": "^4.2.1", "rss-parser": "^3.13.0", - "sharp": "^0.33.5" + "sharp": "^0.33.5", + "fluent-ffmpeg": "^2.1.3" } } \ No newline at end of file diff --git a/backend/routes/admin.js b/backend/routes/admin.js index c9b4169..322f621 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -3,6 +3,10 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import multer from "multer"; import sharp from "sharp"; +import ffmpeg from "fluent-ffmpeg"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; import { S3Client, PutObjectCommand, @@ -555,7 +559,7 @@ router.get("/albums/:albumId/teasers", async (req, res) => { // 티저 조회 const [teasers] = await pool.query( - `SELECT id, original_url, medium_url, thumb_url, sort_order, media_type + `SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order ASC`, @@ -595,7 +599,7 @@ router.delete( const filename = teaser.original_url.split("/").pop(); const basePath = `album/${teaser.folder_name}/teaser`; - // RustFS에서 삭제 (3가지 크기 모두) + // RustFS에서 썸네일 삭제 (3가지 크기 모두) const sizes = ["original", "medium_800", "thumb_400"]; for (const size of sizes) { try { @@ -610,6 +614,21 @@ router.delete( } } + // 비디오 파일 삭제 (video_url이 있는 경우) + if (teaser.video_url) { + const videoFilename = teaser.video_url.split("/").pop(); + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/video/${videoFilename}`, + }) + ); + } catch (s3Error) { + console.error("S3 비디오 삭제 오류:", s3Error); + } + } + // 티저 삭제 await connection.query("DELETE FROM album_teasers WHERE id = ?", [ teaserId, @@ -689,7 +708,7 @@ router.post( // 진행률 전송 sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); - let originalUrl, mediumUrl, thumbUrl; + let originalUrl, mediumUrl, thumbUrl, videoUrl; let originalBuffer, originalMeta; // 컨셉 포토: photo/, 티저: teaser/ @@ -698,19 +717,91 @@ router.post( if (isVideo) { // ===== 비디오 파일 처리 (티저 전용) ===== - // 원본 MP4만 업로드 (리사이즈 없음) - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/original/${filename}`, - Body: file.buffer, - ContentType: "video/mp4", - }) - ); + const tempDir = os.tmpdir(); + const tempVideoPath = path.join(tempDir, `video_${Date.now()}.mp4`); + const tempThumbPath = path.join(tempDir, `thumb_${Date.now()}.png`); + const thumbFilename = `${orderNum}.webp`; - originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; - mediumUrl = originalUrl; // 비디오는 원본만 사용 - thumbUrl = originalUrl; + try { + // 1. 임시 파일로 MP4 저장 + await fs.writeFile(tempVideoPath, file.buffer); + + // 2. ffmpeg로 첫 프레임 추출 (썸네일) + await new Promise((resolve, reject) => { + ffmpeg(tempVideoPath) + .screenshots({ + timestamps: ["00:00:00.001"], + filename: path.basename(tempThumbPath), + folder: tempDir, + }) + .on("end", resolve) + .on("error", reject); + }); + + // 3. 추출된 썸네일을 Sharp로 3가지 크기로 변환 + const thumbBuffer = await fs.readFile(tempThumbPath); + const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([ + sharp(thumbBuffer).webp({ lossless: true }).toBuffer(), + sharp(thumbBuffer) + .resize(800, null, { withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(), + sharp(thumbBuffer) + .resize(400, null, { withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(), + ]); + + // 4. 썸네일 이미지들과 MP4 업로드 (병렬) + await Promise.all([ + // 썸네일 original + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/original/${thumbFilename}`, + Body: origBuf, + ContentType: "image/webp", + }) + ), + // 썸네일 medium + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/medium_800/${thumbFilename}`, + Body: medium800Buffer, + ContentType: "image/webp", + }) + ), + // 썸네일 thumb + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/thumb_400/${thumbFilename}`, + Body: thumb400Buffer, + ContentType: "image/webp", + }) + ), + // 원본 MP4 + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/video/${filename}`, + Body: file.buffer, + ContentType: "video/mp4", + }) + ), + ]); + + // 5. URL 설정 (썸네일은 WebP, 비디오는 MP4) + originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${thumbFilename}`; + mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${thumbFilename}`; + thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${thumbFilename}`; + videoUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/video/${filename}`; + } finally { + // 임시 파일 정리 + await fs.unlink(tempVideoPath).catch(() => {}); + await fs.unlink(tempThumbPath).catch(() => {}); + } } else { // ===== 이미지 파일 처리 ===== // Sharp로 이미지 처리 (병렬) @@ -769,14 +860,15 @@ router.post( // 티저 이미지/비디오 → album_teasers 테이블 const mediaType = isVideo ? "video" : "image"; const [result] = await connection.query( - `INSERT INTO album_teasers - (album_id, original_url, medium_url, thumb_url, sort_order, media_type) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO album_teasers + (album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type) + VALUES (?, ?, ?, ?, ?, ?, ?)`, [ albumId, originalUrl, mediumUrl, thumbUrl, + videoUrl || null, nextOrder + i, mediaType, ] @@ -819,6 +911,7 @@ router.post( original_url: originalUrl, medium_url: mediumUrl, thumb_url: thumbUrl, + video_url: videoUrl || null, filename, media_type: isVideo ? "video" : "image", }); diff --git a/backend/routes/albums.js b/backend/routes/albums.js index 2544011..4eed765 100644 --- a/backend/routes/albums.js +++ b/backend/routes/albums.js @@ -12,9 +12,9 @@ async function getAlbumDetails(album) { ); album.tracks = tracks; - // 티저 이미지/비디오 조회 (3개 해상도 URL + media_type 포함) + // 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함) const [teasers] = await pool.query( - "SELECT original_url, medium_url, thumb_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order", + "SELECT original_url, medium_url, thumb_url, video_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order", [album.id] ); album.teasers = teasers; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 092d90b..614432d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,7 +19,7 @@ services: image: node:20-alpine container_name: fromis9-backend working_dir: /app - command: sh -c "npm install && node server.js" + command: sh -c "apk add --no-cache ffmpeg && npm install && node server.js" env_file: - .env environment: diff --git a/frontend/src/pages/mobile/public/AlbumDetail.jsx b/frontend/src/pages/mobile/public/AlbumDetail.jsx index 2493024..70cf718 100644 --- a/frontend/src/pages/mobile/public/AlbumDetail.jsx +++ b/frontend/src/pages/mobile/public/AlbumDetail.jsx @@ -214,34 +214,28 @@ function MobileAlbumDetail() {

티저 포토

{album.teasers.map((teaser, index) => ( -
openLightbox( - album.teasers.map(t => t.original_url), + album.teasers.map(t => + t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url + ), index, { teasers: album.teasers, showNav: true } )} className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm" > - {teaser.media_type === 'video' ? ( - <> -