From 3b5f8a93ca6f95f12ef5b57b6a6295924dfa0319 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 14:15:51 +0900 Subject: [PATCH] =?UTF-8?q?Flutter=20=EC=95=B1:=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다운로드 서비스 개선 - 저장 경로를 Pictures/fromis_9 폴더로 변경 (갤러리 표시) - URL에서 파일 확장자 자동 추출 (동영상/이미지) - saveInPublicStorage 옵션으로 미디어 스캐너 트리거 - 티저 라이트박스에서 동영상 다운로드 지원 - video_thumbnail 패키지 제거 (백엔드에서 썸네일 처리) - 티저 썸네일 위젯 간소화 (StatefulWidget → StatelessWidget) - 홈 화면 하단 여백 수정 Co-Authored-By: Claude Opus 4.5 --- app/lib/services/download_service.dart | 43 ++++-- app/lib/views/album/album_detail_view.dart | 163 ++++----------------- app/lib/views/home/home_view.dart | 1 - app/pubspec.lock | 8 - app/pubspec.yaml | 1 - 5 files changed, 65 insertions(+), 151 deletions(-) diff --git a/app/lib/services/download_service.dart b/app/lib/services/download_service.dart index 9b84715..363d2d4 100644 --- a/app/lib/services/download_service.dart +++ b/app/lib/services/download_service.dart @@ -14,7 +14,32 @@ Future initDownloadService() async { ); } -/// 이미지 다운로드 +/// URL에서 파일 확장자 추출 +String _getExtensionFromUrl(String url) { + try { + final uri = Uri.parse(url); + final path = uri.path.toLowerCase(); + + // 동영상 확장자 + if (path.endsWith('.mp4')) return '.mp4'; + if (path.endsWith('.mov')) return '.mov'; + if (path.endsWith('.avi')) return '.avi'; + if (path.endsWith('.webm')) return '.webm'; + + // 이미지 확장자 + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return '.jpg'; + if (path.endsWith('.png')) return '.png'; + if (path.endsWith('.gif')) return '.gif'; + if (path.endsWith('.webp')) return '.webp'; + + // 기본값 + return '.jpg'; + } catch (_) { + return '.jpg'; + } +} + +/// 파일 다운로드 (이미지/동영상) Future downloadImage(String url, {String? fileName}) async { // 권한 요청 if (Platform.isAndroid) { @@ -24,21 +49,20 @@ Future downloadImage(String url, {String? fileName}) async { } } - // 다운로드 경로 설정 - Directory? directory; + // 다운로드 경로 설정 (Pictures 폴더) + final Directory directory; if (Platform.isAndroid) { - directory = Directory('/storage/emulated/0/Download'); + directory = Directory('/storage/emulated/0/Pictures/fromis_9'); if (!await directory.exists()) { - directory = await getExternalStorageDirectory(); + await directory.create(recursive: true); } } else { directory = await getApplicationDocumentsDirectory(); } - if (directory == null) return null; - - // 파일명 생성 - final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}.jpg'; + // 파일명 생성 (URL에서 확장자 추출) + final extension = _getExtensionFromUrl(url); + final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}$extension'; // 다운로드 시작 final taskId = await FlutterDownloader.enqueue( @@ -47,6 +71,7 @@ Future downloadImage(String url, {String? fileName}) async { fileName: name, showNotification: true, openFileFromNotification: true, + saveInPublicStorage: true, // 갤러리에 표시 ); return taskId; diff --git a/app/lib/views/album/album_detail_view.dart b/app/lib/views/album/album_detail_view.dart index c9a8051..f88c32e 100644 --- a/app/lib/views/album/album_detail_view.dart +++ b/app/lib/views/album/album_detail_view.dart @@ -1,7 +1,6 @@ /// 앨범 상세 화면 library; -import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,8 +12,6 @@ import 'package:photo_view/photo_view_gallery.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:video_player/video_player.dart'; import 'package:chewie/chewie.dart'; -import 'package:video_thumbnail/video_thumbnail.dart'; -import 'package:path_provider/path_provider.dart'; import '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_service.dart'; @@ -481,70 +478,16 @@ class _TeaserSection extends StatelessWidget { } } -/// 티저 썸네일 (동영상의 경우 video_thumbnail으로 1초 프레임 추출) -class _TeaserThumbnail extends StatefulWidget { +/// 티저 썸네일 +class _TeaserThumbnail extends StatelessWidget { final Teaser teaser; const _TeaserThumbnail({required this.teaser}); - @override - State<_TeaserThumbnail> createState() => _TeaserThumbnailState(); -} - -class _TeaserThumbnailState extends State<_TeaserThumbnail> { - String? _thumbnailPath; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - if (widget.teaser.mediaType == 'video' && widget.teaser.thumbUrl == null) { - _extractThumbnail(); - } else { - _isLoading = false; - } - } - - Future _extractThumbnail() async { - if (widget.teaser.originalUrl == null) { - if (mounted) { - setState(() => _isLoading = false); - } - return; - } - - try { - // 임시 디렉토리 경로 획득 - final tempDir = await getTemporaryDirectory(); - - // 썸네일 파일 생성 - final thumbnailPath = await VideoThumbnail.thumbnailFile( - video: widget.teaser.originalUrl!, - thumbnailPath: tempDir.path, - imageFormat: ImageFormat.JPEG, - maxHeight: 200, - quality: 75, - timeMs: 1000, // 1초 위치 - ); - - if (mounted) { - setState(() { - _thumbnailPath = thumbnailPath; - _isLoading = false; - }); - } - } catch (e) { - // 썸네일 추출 실패 - 플레이스홀더 표시 - if (mounted) { - setState(() => _isLoading = false); - } - } - } - @override Widget build(BuildContext context) { - final teaser = widget.teaser; final isVideo = teaser.mediaType == 'video'; + final imageUrl = teaser.thumbUrl ?? teaser.originalUrl; return Container( width: 96, @@ -558,8 +501,16 @@ class _TeaserThumbnailState extends State<_TeaserThumbnail> { child: Stack( fit: StackFit.expand, children: [ - // 이미지 또는 동영상 썸네일 - _buildThumbnail(), + // 썸네일 이미지 + if (imageUrl != null) + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container(color: AppColors.divider), + errorWidget: (context, url, error) => _buildPlaceholder(), + ) + else + _buildPlaceholder(), // 동영상 재생 버튼 오버레이 if (isVideo) Container( @@ -582,54 +533,6 @@ class _TeaserThumbnailState extends State<_TeaserThumbnail> { ); } - 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, @@ -959,14 +862,14 @@ class _TeaserViewerState extends State<_TeaserViewer> { super.dispose(); } - /// 이미지 다운로드 (시스템 다운로드 매니저 사용) - Future _downloadImage() async { + /// 다운로드 (이미지 또는 동영상) + Future _download() async { final teaser = widget.teasers[_currentIndex]; - if (teaser.mediaType == 'video') return; // 동영상은 다운로드 안함 - final imageUrl = teaser.originalUrl; - if (imageUrl == null || imageUrl.isEmpty) return; + final isVideo = teaser.mediaType == 'video'; + final url = isVideo ? (teaser.videoUrl ?? teaser.originalUrl) : teaser.originalUrl; + if (url == null || url.isEmpty) return; - final taskId = await downloadImage(imageUrl); + final taskId = await downloadImage(url); if (taskId != null && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -981,8 +884,6 @@ class _TeaserViewerState extends State<_TeaserViewer> { Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; final topPadding = MediaQuery.of(context).padding.top; - final currentTeaser = widget.teasers[_currentIndex]; - final isVideo = currentTeaser.mediaType == 'video'; return AnnotatedRegion( value: SystemUiOverlayStyle.light, @@ -1072,23 +973,21 @@ class _TeaserViewerState extends State<_TeaserViewer> { fontFeatures: [FontFeature.tabularFigures()], ), ), - // 오른쪽: 다운로드 버튼 (이미지만) + // 오른쪽: 다운로드 버튼 Expanded( child: Align( alignment: Alignment.centerRight, - child: isVideo - ? const SizedBox(width: 30) - : GestureDetector( - onTap: _downloadImage, - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon( - LucideIcons.download, - color: Colors.white70, - size: 22, - ), - ), - ), + child: GestureDetector( + onTap: _download, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon( + LucideIcons.download, + color: Colors.white70, + size: 22, + ), + ), + ), ), ), ], diff --git a/app/lib/views/home/home_view.dart b/app/lib/views/home/home_view.dart index 1366f05..e0d22b7 100644 --- a/app/lib/views/home/home_view.dart +++ b/app/lib/views/home/home_view.dart @@ -205,7 +205,6 @@ class _HomeViewState extends State with TickerProviderStateMixin { _buildMembersSection(), _buildAlbumsSection(), _buildSchedulesSection(), - const SizedBox(height: 16), ], ), ); diff --git a/app/pubspec.lock b/app/pubspec.lock index d9ce412..dc85c1a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -981,14 +981,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" - video_thumbnail: - dependency: "direct main" - description: - name: video_thumbnail - sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b" - url: "https://pub.dev" - source: hosted - version: "0.5.6" vm_service: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 103c6d7..59a912f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -47,7 +47,6 @@ dependencies: flutter_downloader: ^1.11.8 permission_handler: ^11.3.1 modal_bottom_sheet: ^3.0.0 - video_thumbnail: ^0.5.3 video_player: ^2.9.2 chewie: ^1.8.5