비디오 티저 썸네일 추출 기능 추가

- 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>
This commit is contained in:
caadiq 2026-01-13 11:59:12 +09:00
parent 5691cb6ce0
commit 255839a598
17 changed files with 1557 additions and 230 deletions

View file

@ -10,6 +10,9 @@ RUN npm run build
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# ffmpeg 설치 (비디오 썸네일 추출용)
RUN apk add --no-cache ffmpeg
# 백엔드 의존성 설치 # 백엔드 의존성 설치
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm install --production RUN npm install --production

View file

@ -8,6 +8,7 @@ import '../views/home/home_view.dart';
import '../views/members/members_view.dart'; import '../views/members/members_view.dart';
import '../views/album/album_view.dart'; import '../views/album/album_view.dart';
import '../views/album/album_detail_view.dart'; import '../views/album/album_detail_view.dart';
import '../views/album/track_detail_view.dart';
import '../views/schedule/schedule_view.dart'; import '../views/schedule/schedule_view.dart';
/// ///
@ -57,5 +58,18 @@ final GoRouter appRouter = GoRouter(
return AlbumDetailView(albumName: albumName); 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,
);
},
),
], ],
); );

View file

@ -136,12 +136,14 @@ class Teaser {
final int id; final int id;
final String? originalUrl; final String? originalUrl;
final String? thumbUrl; final String? thumbUrl;
final String? videoUrl;
final String? mediaType; final String? mediaType;
Teaser({ Teaser({
required this.id, required this.id,
this.originalUrl, this.originalUrl,
this.thumbUrl, this.thumbUrl,
this.videoUrl,
this.mediaType, this.mediaType,
}); });
@ -150,6 +152,7 @@ class Teaser {
id: (json['id'] as num?)?.toInt() ?? 0, id: (json['id'] as num?)?.toInt() ?? 0,
originalUrl: json['original_url'] as String?, originalUrl: json['original_url'] as String?,
thumbUrl: json['thumb_url'] as String?, thumbUrl: json['thumb_url'] as String?,
videoUrl: json['video_url'] as String?,
mediaType: json['media_type'] 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<String, dynamic> 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<String, dynamic> 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?,
);
}
}

View file

@ -22,3 +22,11 @@ Future<Album> getAlbumByName(String name) async {
final response = await dio.get('/albums/by-name/$name'); final response = await dio.get('/albums/by-name/$name');
return Album.fromJson(response.data); return Album.fromJson(response.data);
} }
/// (, )
Future<TrackDetail> 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);
}

View file

@ -1,6 +1,7 @@
/// ///
library; library;
import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.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 '../../core/constants.dart';
import '../../models/album.dart'; import '../../models/album.dart';
import '../../services/albums_service.dart'; import '../../services/albums_service.dart';
@ -156,6 +161,7 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: _TracksSection( child: _TracksSection(
album: album, album: album,
albumName: widget.albumName,
displayTracks: displayTracks, displayTracks: displayTracks,
showAllTracks: _showAllTracks, showAllTracks: _showAllTracks,
onToggle: () => setState(() => _showAllTracks = !_showAllTracks), onToggle: () => setState(() => _showAllTracks = !_showAllTracks),
@ -444,7 +450,102 @@ class _TeaserSection extends StatelessWidget {
padding: EdgeInsets.only(right: index < teasers.length - 1 ? 12 : 0), padding: EdgeInsets.only(right: index < teasers.length - 1 ? 12 : 0),
child: GestureDetector( child: GestureDetector(
onTap: () => _showImageViewer(context, teasers, index), onTap: () => _showImageViewer(context, teasers, index),
child: Container( child: _TeaserThumbnail(teaser: teaser),
),
);
},
),
),
const SizedBox(height: 16),
],
),
);
}
void _showImageViewer(BuildContext context, List<Teaser> teasers, int initialIndex) {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) {
return _TeaserViewer(
teasers: teasers,
initialIndex: initialIndex,
);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
),
);
}
}
/// ( 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<void> _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, width: 96,
height: 96, height: 96,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -456,16 +557,10 @@ class _TeaserSection extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (teaser.thumbUrl != null || teaser.originalUrl != null) //
CachedNetworkImage( _buildThumbnail(),
imageUrl: teaser.thumbUrl ?? teaser.originalUrl!, //
fit: BoxFit.cover, if (isVideo)
placeholder: (context, url) => Container(
color: AppColors.divider,
),
errorWidget: (context, url, error) => const SizedBox(),
),
if (teaser.mediaType == 'video')
Container( Container(
color: Colors.black.withValues(alpha: 0.3), color: Colors.black.withValues(alpha: 0.3),
child: const Center( child: const Center(
@ -483,31 +578,66 @@ class _TeaserSection extends StatelessWidget {
], ],
), ),
), ),
),
),
); );
}, }
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,
), ),
), ),
const SizedBox(height: 16),
],
), ),
); );
} }
void _showImageViewer(BuildContext context, List<Teaser> teasers, int initialIndex) { //
Navigator.of(context).push( if (_thumbnailPath != null) {
PageRouteBuilder( return Image.file(
opaque: false, File(_thumbnailPath!),
pageBuilder: (context, animation, secondaryAnimation) { fit: BoxFit.cover,
return _TeaserImageViewer( errorBuilder: (context, error, stackTrace) => _buildPlaceholder(),
images: teasers.map((t) => t.originalUrl ?? '').toList(),
initialIndex: initialIndex,
); );
}, }
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child); //
}, return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
color: AppColors.divider,
child: const Center(
child: Icon(
LucideIcons.video,
size: 32,
color: AppColors.textTertiary,
),
), ),
); );
} }
@ -516,12 +646,14 @@ class _TeaserSection extends StatelessWidget {
/// ///
class _TracksSection extends StatelessWidget { class _TracksSection extends StatelessWidget {
final Album album; final Album album;
final String albumName;
final List<Track>? displayTracks; final List<Track>? displayTracks;
final bool showAllTracks; final bool showAllTracks;
final VoidCallback onToggle; final VoidCallback onToggle;
const _TracksSection({ const _TracksSection({
required this.album, required this.album,
required this.albumName,
required this.displayTracks, required this.displayTracks,
required this.showAllTracks, required this.showAllTracks,
required this.onToggle, required this.onToggle,
@ -548,7 +680,10 @@ class _TracksSection extends StatelessWidget {
), ),
), ),
const SizedBox(height: 12), 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) if (album.tracks != null && album.tracks!.length > 5)
GestureDetector( GestureDetector(
onTap: onToggle, onTap: onToggle,
@ -584,12 +719,19 @@ class _TracksSection extends StatelessWidget {
/// ///
class _TrackItem extends StatelessWidget { class _TrackItem extends StatelessWidget {
final Track track; final Track track;
final String albumName;
const _TrackItem({required this.track}); const _TrackItem({required this.track, required this.albumName});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( 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), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
child: Row( child: Row(
children: [ children: [
@ -653,8 +795,16 @@ class _TrackItem extends StatelessWidget {
fontFeatures: [FontFeature.tabularFigures()], fontFeatures: [FontFeature.tabularFigures()],
), ),
), ),
//
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 16,
color: AppColors.textTertiary,
),
], ],
), ),
),
); );
} }
} }
@ -777,21 +927,21 @@ class _ConceptPhotosSection extends StatelessWidget {
} }
} }
/// (, ) /// ( + )
class _TeaserImageViewer extends StatefulWidget { class _TeaserViewer extends StatefulWidget {
final List<String> images; final List<Teaser> teasers;
final int initialIndex; final int initialIndex;
const _TeaserImageViewer({ const _TeaserViewer({
required this.images, required this.teasers,
required this.initialIndex, required this.initialIndex,
}); });
@override @override
State<_TeaserImageViewer> createState() => _TeaserImageViewerState(); State<_TeaserViewer> createState() => _TeaserViewerState();
} }
class _TeaserImageViewerState extends State<_TeaserImageViewer> { class _TeaserViewerState extends State<_TeaserViewer> {
late PageController _pageController; late PageController _pageController;
late int _currentIndex; late int _currentIndex;
final Set<int> _preloadedIndices = {}; final Set<int> _preloadedIndices = {};
@ -813,12 +963,14 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
super.dispose(); super.dispose();
} }
/// ( 2) /// ( 2, )
void _preloadAdjacentImages(int index) { void _preloadAdjacentImages(int index) {
for (int i = index - 2; i <= index + 2; i++) { for (int i = index - 2; i <= index + 2; i++) {
if (i >= 0 && i < widget.images.length && !_preloadedIndices.contains(i)) { if (i >= 0 && i < widget.teasers.length && !_preloadedIndices.contains(i)) {
final url = widget.images[i]; final teaser = widget.teasers[i];
if (url.isNotEmpty) { if (teaser.mediaType != 'video') {
final url = teaser.originalUrl;
if (url != null && url.isNotEmpty) {
_preloadedIndices.add(i); _preloadedIndices.add(i);
precacheImage( precacheImage(
CachedNetworkImageProvider(url), CachedNetworkImageProvider(url),
@ -828,11 +980,14 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
} }
} }
} }
}
/// ( ) /// ( )
Future<void> _downloadImage() async { Future<void> _downloadImage() async {
final imageUrl = widget.images[_currentIndex]; final teaser = widget.teasers[_currentIndex];
if (imageUrl.isEmpty) return; if (teaser.mediaType == 'video') return; //
final imageUrl = teaser.originalUrl;
if (imageUrl == null || imageUrl.isEmpty) return;
final taskId = await downloadImage(imageUrl); final taskId = await downloadImage(imageUrl);
if (taskId != null && mounted) { if (taskId != null && mounted) {
@ -849,6 +1004,8 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom; final bottomPadding = MediaQuery.of(context).padding.bottom;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
final currentTeaser = widget.teasers[_currentIndex];
final isVideo = currentTeaser.mediaType == 'video';
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, value: SystemUiOverlayStyle.light,
@ -856,18 +1013,29 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: Stack(
children: [ children: [
// ( ) //
PhotoViewGallery.builder( PhotoViewGallery.builder(
pageController: _pageController, pageController: _pageController,
itemCount: widget.images.length, itemCount: widget.teasers.length,
onPageChanged: (index) { onPageChanged: (index) {
setState(() => _currentIndex = index); setState(() => _currentIndex = index);
_preloadAdjacentImages(index); _preloadAdjacentImages(index);
}, },
backgroundDecoration: const BoxDecoration(color: Colors.black), backgroundDecoration: const BoxDecoration(color: Colors.black),
builder: (context, index) { builder: (context, index) {
final imageUrl = widget.images[index]; final teaser = widget.teasers[index];
if (imageUrl.isEmpty) { 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( return PhotoViewGalleryPageOptions.customChild(
child: const Center( child: const Center(
child: Icon( child: Icon(
@ -878,8 +1046,10 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
), ),
); );
} }
// PhotoView
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(imageUrl), imageProvider: CachedNetworkImageProvider(teaser.originalUrl ?? imageUrl),
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3, maxScale: PhotoViewComputedScale.covered * 3,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
@ -893,7 +1063,7 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
), ),
), ),
), ),
// - 3 //
Positioned( Positioned(
top: topPadding + 8, top: topPadding + 8,
left: 0, left: 0,
@ -916,24 +1086,30 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
), ),
), ),
// : // :
if (widget.images.length > 1) if (widget.teasers.length > 1)
Text( Text(
'${_currentIndex + 1} / ${widget.images.length}', '${_currentIndex + 1} / ${widget.teasers.length}',
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 14, fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()], fontFeatures: [FontFeature.tabularFigures()],
), ),
), ),
// : // : ()
Expanded( Expanded(
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: GestureDetector( child: isVideo
? const SizedBox(width: 30)
: GestureDetector(
onTap: _downloadImage, onTap: _downloadImage,
child: const Padding( child: const Padding(
padding: EdgeInsets.all(4), padding: EdgeInsets.all(4),
child: Icon(LucideIcons.download, color: Colors.white70, size: 22), child: Icon(
LucideIcons.download,
color: Colors.white70,
size: 22,
),
), ),
), ),
), ),
@ -943,13 +1119,13 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
), ),
), ),
// //
if (widget.images.length > 1) if (widget.teasers.length > 1)
Positioned( Positioned(
bottom: bottomPadding + 16, bottom: bottomPadding + 16,
left: 0, left: 0,
right: 0, right: 0,
child: _SlidingIndicator( child: _SlidingIndicator(
count: widget.images.length, count: widget.teasers.length,
currentIndex: _currentIndex, currentIndex: _currentIndex,
onTap: (index) { onTap: (index) {
_pageController.animateToPage( _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<void> _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 { class _SingleImageViewer extends StatelessWidget {
final String imageUrl; final String imageUrl;

View file

@ -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<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),
],
),
),
],
),
);
}
}

View file

@ -5,12 +5,18 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import package_info_plus
import path_provider_foundation import path_provider_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import video_player_avfoundation
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
} }

View file

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: cli_config:
dependency: transitive dependency: transitive
description: description:
@ -121,6 +129,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -129,6 +145,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@ -256,6 +280,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "17.0.1" version: "17.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -392,6 +424,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:
@ -416,6 +456,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -568,6 +624,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" 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: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -877,6 +941,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: vm_service:
dependency: transitive dependency: transitive
description: description:
@ -885,6 +997,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" 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: watcher:
dependency: transitive dependency: transitive
description: description:
@ -925,6 +1053,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View file

@ -47,6 +47,9 @@ dependencies:
flutter_downloader: ^1.11.8 flutter_downloader: ^1.11.8
permission_handler: ^11.3.1 permission_handler: ^11.3.1
modal_bottom_sheet: ^3.0.0 modal_bottom_sheet: ^3.0.0
video_thumbnail: ^0.5.3
video_player: ^2.9.2
chewie: ^1.8.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -12,6 +12,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"express": "^4.18.2", "express": "^4.18.2",
"fluent-ffmpeg": "^2.1.3",
"inko": "^1.1.1", "inko": "^1.1.1",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@ -2055,6 +2056,11 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@ -2554,6 +2560,20 @@
"node": ">= 0.8" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2849,6 +2869,12 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -3780,6 +3806,18 @@
"node": ">= 0.8" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View file

@ -19,6 +19,7 @@
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sharp": "^0.33.5" "sharp": "^0.33.5",
"fluent-ffmpeg": "^2.1.3"
} }
} }

View file

@ -3,6 +3,10 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import multer from "multer"; import multer from "multer";
import sharp from "sharp"; import sharp from "sharp";
import ffmpeg from "fluent-ffmpeg";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { import {
S3Client, S3Client,
PutObjectCommand, PutObjectCommand,
@ -555,7 +559,7 @@ router.get("/albums/:albumId/teasers", async (req, res) => {
// 티저 조회 // 티저 조회
const [teasers] = await pool.query( 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 FROM album_teasers
WHERE album_id = ? WHERE album_id = ?
ORDER BY sort_order ASC`, ORDER BY sort_order ASC`,
@ -595,7 +599,7 @@ router.delete(
const filename = teaser.original_url.split("/").pop(); const filename = teaser.original_url.split("/").pop();
const basePath = `album/${teaser.folder_name}/teaser`; const basePath = `album/${teaser.folder_name}/teaser`;
// RustFS에서 삭제 (3가지 크기 모두) // RustFS에서 썸네일 삭제 (3가지 크기 모두)
const sizes = ["original", "medium_800", "thumb_400"]; const sizes = ["original", "medium_800", "thumb_400"];
for (const size of sizes) { for (const size of sizes) {
try { 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 = ?", [ await connection.query("DELETE FROM album_teasers WHERE id = ?", [
teaserId, teaserId,
@ -689,7 +708,7 @@ router.post(
// 진행률 전송 // 진행률 전송
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
let originalUrl, mediumUrl, thumbUrl; let originalUrl, mediumUrl, thumbUrl, videoUrl;
let originalBuffer, originalMeta; let originalBuffer, originalMeta;
// 컨셉 포토: photo/, 티저: teaser/ // 컨셉 포토: photo/, 티저: teaser/
@ -698,19 +717,91 @@ router.post(
if (isVideo) { if (isVideo) {
// ===== 비디오 파일 처리 (티저 전용) ===== // ===== 비디오 파일 처리 (티저 전용) =====
// 원본 MP4만 업로드 (리사이즈 없음) const tempDir = os.tmpdir();
await s3Client.send( const tempVideoPath = path.join(tempDir, `video_${Date.now()}.mp4`);
const tempThumbPath = path.join(tempDir, `thumb_${Date.now()}.png`);
const thumbFilename = `${orderNum}.webp`;
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({ new PutObjectCommand({
Bucket: BUCKET, Bucket: BUCKET,
Key: `${basePath}/original/${filename}`, 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, Body: file.buffer,
ContentType: "video/mp4", ContentType: "video/mp4",
}) })
); ),
]);
originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; // 5. URL 설정 (썸네일은 WebP, 비디오는 MP4)
mediumUrl = originalUrl; // 비디오는 원본만 사용 originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${thumbFilename}`;
thumbUrl = originalUrl; 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 { } else {
// ===== 이미지 파일 처리 ===== // ===== 이미지 파일 처리 =====
// Sharp로 이미지 처리 (병렬) // Sharp로 이미지 처리 (병렬)
@ -770,13 +861,14 @@ router.post(
const mediaType = isVideo ? "video" : "image"; const mediaType = isVideo ? "video" : "image";
const [result] = await connection.query( const [result] = await connection.query(
`INSERT INTO album_teasers `INSERT INTO album_teasers
(album_id, original_url, medium_url, thumb_url, sort_order, media_type) (album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
[ [
albumId, albumId,
originalUrl, originalUrl,
mediumUrl, mediumUrl,
thumbUrl, thumbUrl,
videoUrl || null,
nextOrder + i, nextOrder + i,
mediaType, mediaType,
] ]
@ -819,6 +911,7 @@ router.post(
original_url: originalUrl, original_url: originalUrl,
medium_url: mediumUrl, medium_url: mediumUrl,
thumb_url: thumbUrl, thumb_url: thumbUrl,
video_url: videoUrl || null,
filename, filename,
media_type: isVideo ? "video" : "image", media_type: isVideo ? "video" : "image",
}); });

View file

@ -12,9 +12,9 @@ async function getAlbumDetails(album) {
); );
album.tracks = tracks; album.tracks = tracks;
// 티저 이미지/비디오 조회 (3개 해상도 URL + media_type 포함) // 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함)
const [teasers] = await pool.query( 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.id]
); );
album.teasers = teasers; album.teasers = teasers;

View file

@ -19,7 +19,7 @@ services:
image: node:20-alpine image: node:20-alpine
container_name: fromis9-backend container_name: fromis9-backend
working_dir: /app 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_file:
- .env - .env
environment: environment:

View file

@ -217,31 +217,25 @@ function MobileAlbumDetail() {
<div <div
key={index} key={index}
onClick={() => openLightbox( onClick={() => 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, index,
{ teasers: album.teasers, showNav: true } { teasers: album.teasers, showNav: true }
)} )}
className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm" className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
> >
{teaser.media_type === 'video' ? (
<>
<video
src={teaser.original_url}
className="w-full h-full object-cover"
muted
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
</div>
</div>
</>
) : (
<img <img
src={teaser.thumb_url || teaser.original_url} src={teaser.thumb_url || teaser.original_url}
alt={`Teaser ${index + 1}`} alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
{teaser.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
</div>
</div>
)} )}
</div> </div>
))} ))}

View file

@ -1381,7 +1381,8 @@ function AdminAlbumPhotos() {
> >
{teaser.media_type === 'video' ? ( {teaser.media_type === 'video' ? (
<video <video
src={teaser.original_url} src={teaser.video_url || teaser.original_url}
poster={teaser.thumb_url}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
muted muted
loop loop

View file

@ -295,33 +295,29 @@ function AlbumDetail() {
key={index} key={index}
onClick={() => setLightbox({ onClick={() => setLightbox({
open: true, open: true,
images: album.teasers.map(t => t.original_url), images: album.teasers.map(t =>
t.media_type === 'video' ? (t.video_url || t.original_url) : t.original_url
),
index, index,
teasers: album.teasers // media_type teasers: album.teasers // media_type
})} })}
className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative" className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative"
> >
{teaser.media_type === 'video' ? (
<> <>
<video
src={teaser.original_url}
className="w-full h-full object-cover"
muted
/>
{/* 비디오 아이콘 오버레이 */}
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[10px] border-l-gray-800 border-y-[6px] border-y-transparent ml-1" />
</div>
</div>
</>
) : (
<img <img
src={teaser.thumb_url} src={teaser.thumb_url}
alt={`Teaser ${index + 1}`} alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
{/* 비디오 아이콘 오버레이 */}
{teaser.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[10px] border-l-gray-800 border-y-[6px] border-y-transparent ml-1" />
</div>
</div>
)} )}
</>
</div> </div>
))} ))}
</div> </div>