/// 앨범 상세 화면 library; import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.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:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'package:video_player/video_player.dart'; import 'package:chewie/chewie.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; import 'package:path_provider/path_provider.dart'; import '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_service.dart'; import '../../services/download_service.dart'; class AlbumDetailView extends StatefulWidget { final String albumName; const AlbumDetailView({super.key, required this.albumName}); @override State createState() => _AlbumDetailViewState(); } class _AlbumDetailViewState extends State { late Future _albumFuture; bool _showAllTracks = false; @override void initState() { super.initState(); _albumFuture = getAlbumByName(widget.albumName); } String _formatDate(String? date) { if (date == null || date.length < 10) return ''; return '${date.substring(0, 4)}.${date.substring(5, 7)}.${date.substring(8, 10)}'; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: FutureBuilder( future: _albumFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (snapshot.hasError) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(LucideIcons.alertCircle, size: 48, color: AppColors.textTertiary), const SizedBox(height: 16), Text( '앨범을 불러오는데 실패했습니다', style: const TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( snapshot.error.toString(), style: const TextStyle(fontSize: 12, color: AppColors.textTertiary), textAlign: TextAlign.center, ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () { setState(() { _albumFuture = getAlbumByName(widget.albumName); }); }, child: const Text('다시 시도'), ), TextButton( onPressed: () => context.pop(), child: const Text('뒤로 가기'), ), ], ), ], ), ); } if (!snapshot.hasData) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(LucideIcons.disc3, size: 48, color: AppColors.textTertiary), const SizedBox(height: 16), const Text('앨범을 찾을 수 없습니다'), const SizedBox(height: 16), TextButton( onPressed: () => context.pop(), child: const Text('뒤로 가기'), ), ], ), ); } final album = snapshot.data!; final allPhotos = album.allConceptPhotos; final displayTracks = _showAllTracks ? album.tracks : album.tracks?.take(5).toList(); 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: _HeroSection(album: album, formatDate: _formatDate), ), // 티저 포토 if (album.teasers != null && album.teasers!.isNotEmpty) SliverToBoxAdapter( child: _TeaserSection(teasers: album.teasers!), ), // 수록곡 if (album.tracks != null && album.tracks!.isNotEmpty) SliverToBoxAdapter( child: _TracksSection( album: album, albumName: widget.albumName, displayTracks: displayTracks, showAllTracks: _showAllTracks, onToggle: () => setState(() => _showAllTracks = !_showAllTracks), ), ), // 컨셉 포토 if (allPhotos.isNotEmpty) SliverToBoxAdapter( child: _ConceptPhotosSection( photos: allPhotos, albumName: widget.albumName, ), ), // 하단 여백 (컨셉 포토가 없을 때만) if (allPhotos.isEmpty) SliverToBoxAdapter( child: SizedBox(height: 16 + MediaQuery.of(context).padding.bottom), ), ], ); }, ), ); } } /// 히어로 섹션 class _HeroSection extends StatelessWidget { final Album album; final String Function(String?) formatDate; const _HeroSection({required this.album, required this.formatDate}); @override Widget build(BuildContext context) { return ClipRect( child: Stack( clipBehavior: Clip.hardEdge, children: [ // 배경 블러 이미지 if (album.coverMediumUrl != null) Positioned.fill( child: ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), child: Opacity( opacity: 0.3, child: CachedNetworkImage( imageUrl: album.coverMediumUrl!, fit: BoxFit.cover, ), ), ), ), // 그라데이션 오버레이 Positioned.fill( child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white.withValues(alpha: 0.6), Colors.white.withValues(alpha: 0.8), AppColors.background, ], ), ), ), ), // 콘텐츠 Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Column( children: [ // 앨범 커버 Container( width: 176, height: 176, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), blurRadius: 20, offset: const Offset(0, 8), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: album.coverMediumUrl != null ? CachedNetworkImage( imageUrl: 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, size: 64, color: AppColors.textTertiary, ), ), ) : Container( color: AppColors.divider, child: const Icon( LucideIcons.disc3, size: 64, color: AppColors.textTertiary, ), ), ), ), const SizedBox(height: 16), // 앨범 타입 뱃지 Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: Text( album.albumType ?? '', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.primary, ), ), ), const SizedBox(height: 8), // 앨범 제목 Text( album.title, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), textAlign: TextAlign.center, ), const SizedBox(height: 12), // 메타 정보 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _MetaItem( icon: LucideIcons.calendar, text: formatDate(album.releaseDate), ), const SizedBox(width: 16), _MetaItem( icon: LucideIcons.music2, text: '${album.tracks?.length ?? 0}곡', ), const SizedBox(width: 16), _MetaItem( icon: LucideIcons.clock, text: album.totalDuration, ), ], ), // 앨범 소개 버튼 if (album.description != null && album.description!.isNotEmpty) ...[ const SizedBox(height: 12), GestureDetector( onTap: () => _showDescriptionModal(context, album.description!), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 4, ), ], ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.fileText, size: 14, color: AppColors.textSecondary), SizedBox(width: 4), Text( '앨범 소개', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ), ), ], ], ), ), ], ), ); } void _showDescriptionModal(BuildContext context, String description) { showBarModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (context) => _DescriptionContent(description: description), ); } } /// 메타 정보 아이템 class _MetaItem extends StatelessWidget { final IconData icon; final String text; const _MetaItem({required this.icon, required this.text}); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 14, color: AppColors.textSecondary), const SizedBox(width: 4), Text( text, style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), ], ); } } /// 티저 포토 섹션 class _TeaserSection extends StatelessWidget { final List teasers; const _TeaserSection({required this.teasers}); @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: AppColors.background, border: Border( bottom: BorderSide(color: AppColors.divider), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 12), child: Text( '티저 포토', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), ), SizedBox( height: 96, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), clipBehavior: Clip.none, itemCount: teasers.length, itemBuilder: (context, index) { final teaser = teasers[index]; return Padding( padding: EdgeInsets.only(right: index < teasers.length - 1 ? 12 : 0), child: GestureDetector( onTap: () => _showImageViewer(context, teasers, index), child: _TeaserThumbnail(teaser: teaser), ), ); }, ), ), const SizedBox(height: 16), ], ), ); } void _showImageViewer(BuildContext context, List teasers, int initialIndex) { Navigator.of(context).push( PageRouteBuilder( opaque: false, pageBuilder: (context, animation, secondaryAnimation) { return _TeaserViewer( teasers: teasers, initialIndex: initialIndex, ); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, ), ); } } /// 티저 썸네일 (동영상의 경우 video_thumbnail으로 1초 프레임 추출) class _TeaserThumbnail extends StatefulWidget { final Teaser teaser; const _TeaserThumbnail({required this.teaser}); @override State<_TeaserThumbnail> createState() => _TeaserThumbnailState(); } class _TeaserThumbnailState extends State<_TeaserThumbnail> { String? _thumbnailPath; bool _isLoading = true; @override void initState() { super.initState(); if (widget.teaser.mediaType == 'video' && widget.teaser.thumbUrl == null) { _extractThumbnail(); } else { _isLoading = false; } } Future _extractThumbnail() async { if (widget.teaser.originalUrl == null) { if (mounted) { setState(() => _isLoading = false); } return; } try { // 임시 디렉토리 경로 획득 final tempDir = await getTemporaryDirectory(); // 썸네일 파일 생성 final thumbnailPath = await VideoThumbnail.thumbnailFile( video: widget.teaser.originalUrl!, thumbnailPath: tempDir.path, imageFormat: ImageFormat.JPEG, maxHeight: 200, quality: 75, timeMs: 1000, // 1초 위치 ); if (mounted) { setState(() { _thumbnailPath = thumbnailPath; _isLoading = false; }); } } catch (e) { // 썸네일 추출 실패 - 플레이스홀더 표시 if (mounted) { setState(() => _isLoading = false); } } } @override Widget build(BuildContext context) { final teaser = widget.teaser; final isVideo = teaser.mediaType == 'video'; return Container( width: 96, height: 96, decoration: BoxDecoration( color: AppColors.divider, borderRadius: BorderRadius.circular(16), ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Stack( fit: StackFit.expand, children: [ // 이미지 또는 동영상 썸네일 _buildThumbnail(), // 동영상 재생 버튼 오버레이 if (isVideo) Container( color: Colors.black.withValues(alpha: 0.3), child: const Center( child: CircleAvatar( radius: 16, backgroundColor: Colors.white, child: Icon( LucideIcons.play, size: 18, color: AppColors.textPrimary, ), ), ), ), ], ), ), ); } Widget _buildThumbnail() { final teaser = widget.teaser; final isVideo = teaser.mediaType == 'video'; // 이미지이거나 thumbUrl이 있는 경우 if (!isVideo || teaser.thumbUrl != null) { final imageUrl = teaser.thumbUrl ?? teaser.originalUrl; if (imageUrl != null) { return CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, placeholder: (context, url) => Container(color: AppColors.divider), errorWidget: (context, url, error) => _buildPlaceholder(), ); } return _buildPlaceholder(); } // 동영상 썸네일 로딩 중 if (_isLoading) { return Container( color: AppColors.divider, child: const Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.textTertiary, ), ), ), ); } // 동영상 썸네일 추출 성공 if (_thumbnailPath != null) { return Image.file( File(_thumbnailPath!), fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => _buildPlaceholder(), ); } // 에러 발생 시 플레이스홀더 return _buildPlaceholder(); } Widget _buildPlaceholder() { return Container( color: AppColors.divider, child: const Center( child: Icon( LucideIcons.video, size: 32, color: AppColors.textTertiary, ), ), ); } } /// 수록곡 섹션 class _TracksSection extends StatelessWidget { final Album album; final String albumName; final List? displayTracks; final bool showAllTracks; final VoidCallback onToggle; const _TracksSection({ required this.album, required this.albumName, required this.displayTracks, required this.showAllTracks, required this.onToggle, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: const BoxDecoration( color: AppColors.background, border: Border( bottom: BorderSide(color: AppColors.divider), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '수록곡', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 12), ...?displayTracks?.map((track) => _TrackItem( track: track, albumName: albumName, )), if (album.tracks != null && album.tracks!.length > 5) GestureDetector( onTap: onToggle, child: Padding( padding: const EdgeInsets.only(top: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( showAllTracks ? '접기' : '${album.tracks!.length - 5}곡 더보기', style: const TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), Icon( showAllTracks ? LucideIcons.chevronUp : LucideIcons.chevronDown, size: 18, color: AppColors.textSecondary, ), ], ), ), ), ], ), ); } } /// 트랙 아이템 class _TrackItem extends StatelessWidget { final Track track; final String albumName; const _TrackItem({required this.track, required this.albumName}); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { final encodedTrackTitle = Uri.encodeComponent(track.title); context.push('/album/$albumName/track/$encodedTrackTitle'); }, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), child: Row( children: [ // 트랙 번호 SizedBox( width: 24, child: Text( track.trackNumber.toString().padLeft(2, '0'), style: const TextStyle( fontSize: 14, color: AppColors.textTertiary, fontFeatures: [FontFeature.tabularFigures()], ), ), ), const SizedBox(width: 12), // 트랙 제목 Expanded( child: Row( children: [ Flexible( child: Text( track.title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: track.isTitleTrack == 1 ? AppColors.primary : AppColors.textPrimary, ), overflow: TextOverflow.ellipsis, ), ), if (track.isTitleTrack == 1) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(4), ), child: const Text( 'TITLE', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ], ], ), ), // 재생 시간 Text( track.duration ?? '-', style: const TextStyle( fontSize: 12, color: AppColors.textTertiary, fontFeatures: [FontFeature.tabularFigures()], ), ), // 화살표 아이콘 const SizedBox(width: 8), const Icon( LucideIcons.chevronRight, size: 16, color: AppColors.textTertiary, ), ], ), ), ); } } /// 컨셉 포토 섹션 class _ConceptPhotosSection extends StatelessWidget { final List photos; final String albumName; const _ConceptPhotosSection({required this.photos, required this.albumName}); @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; return Container( color: AppColors.background, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 12), child: Text( '컨셉 포토', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), ), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16), clipBehavior: Clip.none, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 8, ), itemCount: photos.length > 6 ? 6 : photos.length, itemBuilder: (context, index) { final photo = photos[index]; return GestureDetector( onTap: () => _showImageViewer(context, photo), child: Container( decoration: BoxDecoration( color: AppColors.divider, borderRadius: BorderRadius.circular(12), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: photo.thumbUrl != null || photo.mediumUrl != null ? CachedNetworkImage( imageUrl: photo.thumbUrl ?? photo.mediumUrl!, fit: BoxFit.cover, placeholder: (context, url) => Container( color: AppColors.divider, ), errorWidget: (context, url, error) => const SizedBox(), ) : const SizedBox(), ), ), ); }, ), // 전체보기 버튼 if (photos.length > 6) Padding( padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomPadding), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { // TODO: 갤러리 페이지로 이동 }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary.withValues(alpha: 0.05), foregroundColor: AppColors.primary, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('전체 ${photos.length}장 보기'), const SizedBox(width: 4), const Icon(LucideIcons.chevronRight, size: 18), ], ), ), ), ), // 전체보기 버튼이 없을 때의 하단 여백 if (photos.length <= 6) SizedBox(height: 16 + bottomPadding), ], ), ); } void _showImageViewer(BuildContext context, ConceptPhoto photo) { Navigator.of(context).push( PageRouteBuilder( opaque: false, pageBuilder: (context, animation, secondaryAnimation) { return _SingleImageViewer( imageUrl: photo.originalUrl ?? '', ); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, ), ); } } /// 티저 뷰어 (이미지 + 동영상 지원) class _TeaserViewer extends StatefulWidget { final List teasers; final int initialIndex; const _TeaserViewer({ required this.teasers, required this.initialIndex, }); @override State<_TeaserViewer> createState() => _TeaserViewerState(); } class _TeaserViewerState extends State<_TeaserViewer> { late PageController _pageController; late int _currentIndex; final Set _preloadedIndices = {}; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; _pageController = PageController(initialPage: widget.initialIndex); // 초기 로드 시 주변 이미지 프리로드 WidgetsBinding.instance.addPostFrameCallback((_) { _preloadAdjacentImages(_currentIndex); }); } @override void dispose() { _pageController.dispose(); super.dispose(); } /// 주변 이미지 프리로드 (좌우 2장씩, 이미지만) void _preloadAdjacentImages(int index) { for (int i = index - 2; i <= index + 2; i++) { if (i >= 0 && i < widget.teasers.length && !_preloadedIndices.contains(i)) { final teaser = widget.teasers[i]; if (teaser.mediaType != 'video') { final url = teaser.originalUrl; if (url != null && url.isNotEmpty) { _preloadedIndices.add(i); precacheImage( CachedNetworkImageProvider(url), context, ); } } } } } /// 이미지 다운로드 (시스템 다운로드 매니저 사용) Future _downloadImage() async { final teaser = widget.teasers[_currentIndex]; if (teaser.mediaType == 'video') return; // 동영상은 다운로드 안함 final imageUrl = teaser.originalUrl; if (imageUrl == null || imageUrl.isEmpty) return; final taskId = await downloadImage(imageUrl); if (taskId != null && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('다운로드를 시작합니다'), duration: Duration(seconds: 2), ), ); } } @override 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, child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // 갤러리 PhotoViewGallery.builder( pageController: _pageController, itemCount: widget.teasers.length, onPageChanged: (index) { setState(() => _currentIndex = index); _preloadAdjacentImages(index); }, backgroundDecoration: const BoxDecoration(color: Colors.black), builder: (context, index) { final teaser = widget.teasers[index]; final isVideoItem = teaser.mediaType == 'video'; // 동영상인 경우 Chewie 플레이어로 재생 if (isVideoItem) { return PhotoViewGalleryPageOptions.customChild( child: _VideoTeaserPage(teaser: teaser), ); } // 이미지인 경우 final imageUrl = teaser.thumbUrl ?? teaser.originalUrl; if (imageUrl == null || imageUrl.isEmpty) { return PhotoViewGalleryPageOptions.customChild( child: const Center( child: Icon( LucideIcons.imageOff, color: Colors.white54, size: 64, ), ), ); } // 이미지인 경우 PhotoView 사용 return PhotoViewGalleryPageOptions( imageProvider: CachedNetworkImageProvider(teaser.originalUrl ?? imageUrl), minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3, initialScale: PhotoViewComputedScale.contained, heroAttributes: PhotoViewHeroAttributes(tag: 'teaser_$index'), ); }, loadingBuilder: (context, event) => const Center( child: CircularProgressIndicator( color: Colors.white54, strokeWidth: 2, ), ), ), // 상단 헤더 Positioned( top: topPadding + 8, left: 0, right: 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ // 왼쪽: 닫기 버튼 Expanded( child: Align( alignment: Alignment.centerLeft, child: GestureDetector( onTap: () => Navigator.pop(context), child: const Padding( padding: EdgeInsets.all(4), child: Icon(LucideIcons.x, color: Colors.white70, size: 24), ), ), ), ), // 가운데: 페이지 번호 if (widget.teasers.length > 1) Text( '${_currentIndex + 1} / ${widget.teasers.length}', style: const TextStyle( color: Colors.white70, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()], ), ), // 오른쪽: 다운로드 버튼 (이미지만) Expanded( child: Align( alignment: Alignment.centerRight, child: isVideo ? const SizedBox(width: 30) : GestureDetector( onTap: _downloadImage, child: const Padding( padding: EdgeInsets.all(4), child: Icon( LucideIcons.download, color: Colors.white70, size: 22, ), ), ), ), ), ], ), ), ), // 하단 인디케이터 if (widget.teasers.length > 1) Positioned( bottom: bottomPadding + 16, left: 0, right: 0, child: _SlidingIndicator( count: widget.teasers.length, currentIndex: _currentIndex, onTap: (index) { _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }, ), ), ], ), ), ); } } /// 동영상 티저 페이지 (Chewie로 내부 재생) class _VideoTeaserPage extends StatefulWidget { final Teaser teaser; const _VideoTeaserPage({required this.teaser}); @override State<_VideoTeaserPage> createState() => _VideoTeaserPageState(); } class _VideoTeaserPageState extends State<_VideoTeaserPage> { VideoPlayerController? _videoController; ChewieController? _chewieController; bool _isInitialized = false; bool _hasError = false; @override void initState() { super.initState(); _initializePlayer(); } Future _initializePlayer() async { final videoUrl = widget.teaser.videoUrl ?? widget.teaser.originalUrl; if (videoUrl == null) { setState(() => _hasError = true); return; } try { final videoController = VideoPlayerController.networkUrl( Uri.parse(videoUrl), ); _videoController = videoController; await videoController.initialize(); final chewieController = ChewieController( videoPlayerController: videoController, autoPlay: false, looping: false, showControls: true, allowFullScreen: false, allowMuting: true, showOptions: false, placeholder: Container(color: Colors.black), errorBuilder: (context, errorMessage) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(LucideIcons.alertCircle, color: Colors.white54, size: 48), const SizedBox(height: 8), Text( '동영상을 재생할 수 없습니다', style: const TextStyle(color: Colors.white54), ), ], ), ), ); _chewieController = chewieController; if (mounted) { setState(() => _isInitialized = true); } } catch (e) { if (mounted) { setState(() => _hasError = true); } } } @override void dispose() { _chewieController?.dispose(); _videoController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (_hasError) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.alertCircle, color: Colors.white54, size: 48), SizedBox(height: 8), Text( '동영상을 불러올 수 없습니다', style: TextStyle(color: Colors.white54), ), ], ), ); } if (!_isInitialized || _chewieController == null) { return const Center( child: CircularProgressIndicator( color: Colors.white54, strokeWidth: 2, ), ); } return Center( child: AspectRatio( aspectRatio: _videoController!.value.aspectRatio, child: Chewie(controller: _chewieController!), ), ); } } /// 컨셉 포토용 이미지 뷰어 (단일 이미지, 스와이프 없음) class _SingleImageViewer extends StatelessWidget { final String imageUrl; const _SingleImageViewer({required this.imageUrl}); /// 이미지 다운로드 (시스템 다운로드 매니저 사용) Future _downloadImage(BuildContext context) async { if (imageUrl.isEmpty) return; final taskId = await downloadImage(imageUrl); if (taskId != null && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('다운로드를 시작합니다'), duration: Duration(seconds: 2), ), ); } } @override Widget build(BuildContext context) { final topPadding = MediaQuery.of(context).padding.top; return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // 이미지 (핀치줌 - 전체 화면으로 확대 가능) imageUrl.isNotEmpty ? PhotoView( imageProvider: CachedNetworkImageProvider(imageUrl), minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3, initialScale: PhotoViewComputedScale.contained, backgroundDecoration: const BoxDecoration(color: Colors.black), loadingBuilder: (context, event) => const Center( child: CircularProgressIndicator( color: Colors.white54, strokeWidth: 2, ), ), errorBuilder: (context, error, stackTrace) => const Center( child: Icon( LucideIcons.imageOff, color: Colors.white54, size: 64, ), ), ) : const Center( child: Icon( LucideIcons.imageOff, color: Colors.white54, size: 64, ), ), // 상단 헤더 Positioned( top: topPadding + 8, left: 0, right: 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ // 왼쪽: 닫기 버튼 Expanded( child: Align( alignment: Alignment.centerLeft, child: GestureDetector( onTap: () => Navigator.pop(context), child: const Padding( padding: EdgeInsets.all(4), child: Icon(LucideIcons.x, color: Colors.white70, size: 24), ), ), ), ), // 오른쪽: 다운로드 버튼 Expanded( child: Align( alignment: Alignment.centerRight, child: GestureDetector( onTap: () => _downloadImage(context), child: const Padding( padding: EdgeInsets.all(4), child: Icon(LucideIcons.download, color: Colors.white70, size: 22), ), ), ), ), ], ), ), ), ], ), ), ); } } /// 슬라이딩 인디케이터 class _SlidingIndicator extends StatelessWidget { final int count; final int currentIndex; final Function(int) onTap; const _SlidingIndicator({ required this.count, required this.currentIndex, required this.onTap, }); @override Widget build(BuildContext context) { const double width = 120; const double dotSpacing = 18; const double activeDotSize = 12; const double inactiveDotSize = 10; final double halfWidth = width / 2; final double translateX = -(currentIndex * dotSpacing) + halfWidth - (activeDotSize / 2); return Center( child: SizedBox( width: width, height: 20, child: ShaderMask( shaderCallback: (Rect bounds) { return const LinearGradient( colors: [ Colors.transparent, Colors.white, Colors.white, Colors.transparent, ], stops: [0.0, 0.15, 0.85, 1.0], ).createShader(bounds); }, blendMode: BlendMode.dstIn, child: Stack( children: [ // 슬라이딩 점들 AnimatedPositioned( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, left: translateX, top: 0, bottom: 0, child: Row( mainAxisSize: MainAxisSize.min, children: List.generate(count, (index) { final isActive = index == currentIndex; return GestureDetector( onTap: () => onTap(index), child: Container( width: dotSpacing, alignment: Alignment.center, child: AnimatedContainer( duration: const Duration(milliseconds: 300), width: isActive ? activeDotSize : inactiveDotSize, height: isActive ? activeDotSize : inactiveDotSize, decoration: BoxDecoration( shape: BoxShape.circle, color: isActive ? Colors.white : Colors.white.withValues(alpha: 0.4), ), ), ), ); }), ), ), ], ), ), ), ); } } /// 앨범 소개 내용 class _DescriptionContent extends StatelessWidget { final String description; const _DescriptionContent({required this.description}); @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; return Column( mainAxisSize: MainAxisSize.min, children: [ // 헤더 (고정) Padding( padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '앨범 소개', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), GestureDetector( onTap: () => Navigator.pop(context), child: const Padding( padding: EdgeInsets.all(8), child: Icon(LucideIcons.x, size: 22), ), ), ], ), ), // 구분선 Container( height: 0.5, color: AppColors.divider, ), // 내용 (스크롤) Flexible( child: SingleChildScrollView( controller: ModalScrollController.of(context), padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomPadding), child: Text( description, style: const TextStyle( fontSize: 14, height: 1.8, color: AppColors.textSecondary, ), textAlign: TextAlign.justify, ), ), ), ], ); } }