Flutter 앱: 다운로드 개선 및 불필요한 패키지 제거

- 다운로드 서비스 개선
  - 저장 경로를 Pictures/fromis_9 폴더로 변경 (갤러리 표시)
  - URL에서 파일 확장자 자동 추출 (동영상/이미지)
  - saveInPublicStorage 옵션으로 미디어 스캐너 트리거
- 티저 라이트박스에서 동영상 다운로드 지원
- video_thumbnail 패키지 제거 (백엔드에서 썸네일 처리)
- 티저 썸네일 위젯 간소화 (StatefulWidget → StatelessWidget)
- 홈 화면 하단 여백 수정

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 14:15:51 +09:00
parent 6b512f943e
commit 3b5f8a93ca
5 changed files with 65 additions and 151 deletions

View file

@ -14,7 +14,32 @@ Future<void> 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<String?> downloadImage(String url, {String? fileName}) async { Future<String?> downloadImage(String url, {String? fileName}) async {
// //
if (Platform.isAndroid) { if (Platform.isAndroid) {
@ -24,21 +49,20 @@ Future<String?> downloadImage(String url, {String? fileName}) async {
} }
} }
// // (Pictures )
Directory? directory; final Directory directory;
if (Platform.isAndroid) { if (Platform.isAndroid) {
directory = Directory('/storage/emulated/0/Download'); directory = Directory('/storage/emulated/0/Pictures/fromis_9');
if (!await directory.exists()) { if (!await directory.exists()) {
directory = await getExternalStorageDirectory(); await directory.create(recursive: true);
} }
} else { } else {
directory = await getApplicationDocumentsDirectory(); directory = await getApplicationDocumentsDirectory();
} }
if (directory == null) return null; // (URL에서 )
final extension = _getExtensionFromUrl(url);
// final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}$extension';
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}.jpg';
// //
final taskId = await FlutterDownloader.enqueue( final taskId = await FlutterDownloader.enqueue(
@ -47,6 +71,7 @@ Future<String?> downloadImage(String url, {String? fileName}) async {
fileName: name, fileName: name,
showNotification: true, showNotification: true,
openFileFromNotification: true, openFileFromNotification: true,
saveInPublicStorage: true, //
); );
return taskId; return taskId;

View file

@ -1,7 +1,6 @@
/// ///
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';
@ -13,8 +12,6 @@ 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:video_player/video_player.dart';
import 'package:chewie/chewie.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';
@ -481,70 +478,16 @@ class _TeaserSection extends StatelessWidget {
} }
} }
/// ( video_thumbnail으로 1 ) ///
class _TeaserThumbnail extends StatefulWidget { class _TeaserThumbnail extends StatelessWidget {
final Teaser teaser; final Teaser teaser;
const _TeaserThumbnail({required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final teaser = widget.teaser;
final isVideo = teaser.mediaType == 'video'; final isVideo = teaser.mediaType == 'video';
final imageUrl = teaser.thumbUrl ?? teaser.originalUrl;
return Container( return Container(
width: 96, width: 96,
@ -558,8 +501,16 @@ class _TeaserThumbnailState extends State<_TeaserThumbnail> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ 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) if (isVideo)
Container( 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() { Widget _buildPlaceholder() {
return Container( return Container(
color: AppColors.divider, color: AppColors.divider,
@ -959,14 +862,14 @@ class _TeaserViewerState extends State<_TeaserViewer> {
super.dispose(); super.dispose();
} }
/// ( ) /// ( )
Future<void> _downloadImage() async { Future<void> _download() async {
final teaser = widget.teasers[_currentIndex]; final teaser = widget.teasers[_currentIndex];
if (teaser.mediaType == 'video') return; // final isVideo = teaser.mediaType == 'video';
final imageUrl = teaser.originalUrl; final url = isVideo ? (teaser.videoUrl ?? teaser.originalUrl) : teaser.originalUrl;
if (imageUrl == null || imageUrl.isEmpty) return; if (url == null || url.isEmpty) return;
final taskId = await downloadImage(imageUrl); final taskId = await downloadImage(url);
if (taskId != null && mounted) { if (taskId != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -981,8 +884,6 @@ class _TeaserViewerState extends State<_TeaserViewer> {
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,
@ -1072,14 +973,12 @@ class _TeaserViewerState extends State<_TeaserViewer> {
fontFeatures: [FontFeature.tabularFigures()], fontFeatures: [FontFeature.tabularFigures()],
), ),
), ),
// : () // :
Expanded( Expanded(
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: isVideo child: GestureDetector(
? const SizedBox(width: 30) onTap: _download,
: GestureDetector(
onTap: _downloadImage,
child: const Padding( child: const Padding(
padding: EdgeInsets.all(4), padding: EdgeInsets.all(4),
child: Icon( child: Icon(

View file

@ -205,7 +205,6 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
_buildMembersSection(), _buildMembersSection(),
_buildAlbumsSection(), _buildAlbumsSection(),
_buildSchedulesSection(), _buildSchedulesSection(),
const SizedBox(height: 16),
], ],
), ),
); );

View file

@ -981,14 +981,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" 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:

View file

@ -47,7 +47,6 @@ 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 video_player: ^2.9.2
chewie: ^1.8.5 chewie: ^1.8.5