비디오 티저 썸네일 추출 기능 추가
- 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:
parent
5691cb6ce0
commit
255839a598
17 changed files with 1557 additions and 230 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,46 +450,7 @@ 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),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -500,8 +467,8 @@ class _TeaserSection extends StatelessWidget {
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
opaque: false,
|
opaque: false,
|
||||||
pageBuilder: (context, animation, secondaryAnimation) {
|
pageBuilder: (context, animation, secondaryAnimation) {
|
||||||
return _TeaserImageViewer(
|
return _TeaserViewer(
|
||||||
images: teasers.map((t) => t.originalUrl ?? '').toList(),
|
teasers: teasers,
|
||||||
initialIndex: initialIndex,
|
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<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,
|
||||||
|
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 {
|
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,76 +719,91 @@ 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(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
onTap: () {
|
||||||
child: Row(
|
final encodedTrackTitle = Uri.encodeComponent(track.title);
|
||||||
children: [
|
context.push('/album/$albumName/track/$encodedTrackTitle');
|
||||||
// 트랙 번호
|
},
|
||||||
SizedBox(
|
behavior: HitTestBehavior.opaque,
|
||||||
width: 24,
|
child: Padding(
|
||||||
child: Text(
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||||
track.trackNumber.toString().padLeft(2, '0'),
|
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(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 화살표 아이콘
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
// 트랙 제목
|
const Icon(
|
||||||
Expanded(
|
LucideIcons.chevronRight,
|
||||||
child: Row(
|
size: 16,
|
||||||
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,
|
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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,17 +963,20 @@ 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') {
|
||||||
_preloadedIndices.add(i);
|
final url = teaser.originalUrl;
|
||||||
precacheImage(
|
if (url != null && url.isNotEmpty) {
|
||||||
CachedNetworkImageProvider(url),
|
_preloadedIndices.add(i);
|
||||||
context,
|
precacheImage(
|
||||||
);
|
CachedNetworkImageProvider(url),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -831,8 +984,10 @@ 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,63 +1063,69 @@ class _TeaserImageViewerState extends State<_TeaserImageViewer> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 상단 헤더 - 3등분
|
// 상단 헤더
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topPadding + 8,
|
top: topPadding + 8,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// 왼쪽: 닫기 버튼
|
// 왼쪽: 닫기 버튼
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => Navigator.pop(context),
|
onTap: () => Navigator.pop(context),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.all(4),
|
padding: EdgeInsets.all(4),
|
||||||
child: Icon(LucideIcons.x, color: Colors.white70, size: 24),
|
child: Icon(LucideIcons.x, color: Colors.white70, size: 24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 가운데: 페이지 번호
|
||||||
// 가운데: 페이지 번호
|
if (widget.teasers.length > 1)
|
||||||
if (widget.images.length > 1)
|
Text(
|
||||||
Text(
|
'${_currentIndex + 1} / ${widget.teasers.length}',
|
||||||
'${_currentIndex + 1} / ${widget.images.length}',
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
color: Colors.white70,
|
||||||
color: Colors.white70,
|
fontSize: 14,
|
||||||
fontSize: 14,
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 오른쪽: 다운로드 버튼 (이미지만)
|
||||||
|
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(
|
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;
|
||||||
|
|
|
||||||
668
app/lib/views/album/track_detail_view.dart
Normal file
668
app/lib/views/album/track_detail_view.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
136
app/pubspec.lock
136
app/pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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`);
|
||||||
new PutObjectCommand({
|
const tempThumbPath = path.join(tempDir, `thumb_${Date.now()}.png`);
|
||||||
Bucket: BUCKET,
|
const thumbFilename = `${orderNum}.webp`;
|
||||||
Key: `${basePath}/original/${filename}`,
|
|
||||||
Body: file.buffer,
|
|
||||||
ContentType: "video/mp4",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`;
|
try {
|
||||||
mediumUrl = originalUrl; // 비디오는 원본만 사용
|
// 1. 임시 파일로 MP4 저장
|
||||||
thumbUrl = originalUrl;
|
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 {
|
} else {
|
||||||
// ===== 이미지 파일 처리 =====
|
// ===== 이미지 파일 처리 =====
|
||||||
// Sharp로 이미지 처리 (병렬)
|
// Sharp로 이미지 처리 (병렬)
|
||||||
|
|
@ -769,14 +860,15 @@ router.post(
|
||||||
// 티저 이미지/비디오 → album_teasers 테이블
|
// 티저 이미지/비디오 → album_teasers 테이블
|
||||||
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",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -214,34 +214,28 @@ function MobileAlbumDetail() {
|
||||||
<p className="text-sm font-semibold mb-3">티저 포토</p>
|
<p className="text-sm font-semibold mb-3">티저 포토</p>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide">
|
<div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide">
|
||||||
{album.teasers.map((teaser, index) => (
|
{album.teasers.map((teaser, index) => (
|
||||||
<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' ? (
|
<img
|
||||||
<>
|
src={teaser.thumb_url || teaser.original_url}
|
||||||
<video
|
alt={`Teaser ${index + 1}`}
|
||||||
src={teaser.original_url}
|
className="w-full h-full object-cover"
|
||||||
className="w-full h-full object-cover"
|
/>
|
||||||
muted
|
{teaser.media_type === 'video' && (
|
||||||
/>
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||||
<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-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" />
|
||||||
<Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={teaser.thumb_url || teaser.original_url}
|
|
||||||
alt={`Teaser ${index + 1}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -293,35 +293,31 @@ function AlbumDetail() {
|
||||||
{album.teasers.map((teaser, index) => (
|
{album.teasers.map((teaser, index) => (
|
||||||
<div
|
<div
|
||||||
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' ? (
|
<>
|
||||||
<>
|
<img
|
||||||
<video
|
src={teaser.thumb_url}
|
||||||
src={teaser.original_url}
|
alt={`Teaser ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
muted
|
/>
|
||||||
/>
|
{/* 비디오 아이콘 오버레이 */}
|
||||||
{/* 비디오 아이콘 오버레이 */}
|
{teaser.media_type === 'video' && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
<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-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 className="w-0 h-0 border-l-[10px] border-l-gray-800 border-y-[6px] border-y-transparent ml-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
</>
|
||||||
<img
|
|
||||||
src={teaser.thumb_url}
|
|
||||||
alt={`Teaser ${index + 1}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue