diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6432857..4369cb2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + ) +#import +#else +@import gal; +#endif + #if __has_include() #import #else @@ -36,14 +42,22 @@ @import video_player_avfoundation; #endif +#if __has_include() +#import +#else +@import video_thumbnail; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { [AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]]; [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; + [GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; + [VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]]; } @end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c8466f9..cbc5914 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -8,6 +8,8 @@ MagiEvery AI NSPhotoLibraryUsageDescription We need access to your photos to use as reference for AI image generation. + NSPhotoLibraryAddUsageDescription + We need permission to save videos and images to your photo library. NSCameraUsageDescription We need camera access to capture reference images for AI generation. CFBundleExecutable diff --git a/lib/app.dart b/lib/app.dart index a80e319..f38bbf7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -5,6 +5,7 @@ import 'core/user/user_state.dart'; import 'features/gallery/gallery_screen.dart'; import 'features/generate_video/generate_progress_screen.dart'; import 'features/generate_video/generate_video_screen.dart'; +import 'features/gallery/models/gallery_task_item.dart'; import 'features/generate_video/generation_result_screen.dart'; import 'features/home/home_screen.dart'; import 'features/home/models/task_item.dart'; @@ -54,7 +55,11 @@ class _AppState extends State { final taskId = ModalRoute.of(ctx)?.settings.arguments; return GenerateProgressScreen(taskId: taskId); }, - '/result': (_) => const GenerationResultScreen(), + '/result': (ctx) { + final mediaItem = + ModalRoute.of(ctx)?.settings.arguments as GalleryMediaItem?; + return GenerationResultScreen(mediaItem: mediaItem); + }, }, ), ); @@ -70,18 +75,16 @@ class _MainScaffold extends StatelessWidget { final NavTab currentTab; final ValueChanged onTabSelected; - static const _screens = [ - HomeScreen(), - GalleryScreen(), - ProfileScreen(), - ]; - @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( index: currentTab.index, - children: _screens, + children: [ + const HomeScreen(), + GalleryScreen(isActive: currentTab == NavTab.gallery), + const ProfileScreen(), + ], ), bottomNavigationBar: BottomNavBar( currentTab: currentTab, diff --git a/lib/core/api/services/image_api.dart b/lib/core/api/services/image_api.dart index ba24cd8..b27d15f 100644 --- a/lib/core/api/services/image_api.dart +++ b/lib/core/api/services/image_api.dart @@ -175,6 +175,25 @@ abstract final class ImageApi { ); } + /// 获取我的任务列表 + static Future getMyTasks({ + required String sentinel, + String? trophy, + String? heatmap, + String? platoon, + }) async { + return _client.request( + path: '/v1/image/my-tasks', + method: 'GET', + queryParams: { + 'sentinel': sentinel, + if (trophy != null) 'trophy': trophy, + if (heatmap != null) 'heatmap': heatmap, + if (platoon != null) 'platoon': platoon, + }, + ); + } + /// 获取积分页面信息 static Future getCreditsPageInfo({ required String sentinel, diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index 72b33fe..1a3f152 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -1,21 +1,139 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import '../../core/api/api_config.dart'; +import '../../core/api/services/image_api.dart'; +import '../../core/auth/auth_service.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/user/user_state.dart'; import '../../core/theme/app_spacing.dart'; +import '../../core/user/user_state.dart'; import '../../shared/widgets/top_nav_bar.dart'; -/// Gallery screen - matches Pencil hpwBg -class GalleryScreen extends StatelessWidget { - const GalleryScreen({super.key}); +import 'models/gallery_task_item.dart'; +import 'video_thumbnail_cache.dart'; - static const _galleryImages = [ - 'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400', - 'https://images.unsplash.com/photo-1703592819695-ea63799b7315?w=400', - 'https://images.unsplash.com/photo-1764787435677-1321e12559e3?w=400', - 'https://images.unsplash.com/photo-1759264244741-7175af0b7e75?w=400', - ]; +/// Gallery screen - matches Pencil hpwBg +class GalleryScreen extends StatefulWidget { + const GalleryScreen({super.key, required this.isActive}); + + final bool isActive; + + @override + State createState() => _GalleryScreenState(); +} + +class _GalleryScreenState extends State { + List _tasks = []; + bool _loading = true; + bool _loadingMore = false; + String? _error; + int _currentPage = 1; + bool _hasNext = false; + bool _hasLoadedOnce = false; + final ScrollController _scrollController = ScrollController(); + static const int _pageSize = 20; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + if (widget.isActive) _loadTasks(refresh: true); + } + + @override + void didUpdateWidget(covariant GalleryScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive && !oldWidget.isActive && !_hasLoadedOnce) { + _loadTasks(refresh: true); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_loadingMore || !_hasNext || _tasks.isEmpty) return; + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 200) { + _loadTasks(refresh: false); + } + } + + Future _loadTasks({bool refresh = true}) async { + if (refresh) { + setState(() { + _loading = true; + _error = null; + _currentPage = 1; + }); + } else { + if (_loadingMore || !_hasNext) return; + setState(() => _loadingMore = true); + } + + try { + await AuthService.loginComplete; + final page = refresh ? 1 : _currentPage; + final res = await ImageApi.getMyTasks( + sentinel: ApiConfig.appId, + trophy: page.toString(), + heatmap: _pageSize.toString(), + ); + if (!mounted) return; + if (res.isSuccess && res.data != null) { + final data = res.data as Map?; + final intensify = data?['intensify'] as List? ?? []; + + final list = intensify + .whereType>() + .map((e) => GalleryTaskItem.fromJson(e)) + .toList(); + final hasNext = data?['manifest'] as bool? ?? false; + + setState(() { + if (refresh) { + _tasks = list; + } else { + _tasks = [..._tasks, ...list]; + } + _currentPage = page + 1; + _hasNext = hasNext; + _loading = false; + _loadingMore = false; + _hasLoadedOnce = true; + }); + } else { + setState(() { + if (refresh) _tasks = []; + _loading = false; + _loadingMore = false; + _error = res.msg.isNotEmpty ? res.msg : 'Failed to load'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + if (refresh) _tasks = []; + _loading = false; + _loadingMore = false; + _error = e.toString(); + }); + } + } + } + + List get _gridItems { + final items = []; + for (final task in _tasks) { + items.addAll(task.mediaItems); + } + return items; + } @override Widget build(BuildContext context) { @@ -29,75 +147,181 @@ class GalleryScreen extends StatelessWidget { onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'), ), ), - body: LayoutBuilder( + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.lg), + TextButton( + onPressed: _loadTasks, + child: const Text('Retry'), + ), + ], + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 390), + child: RefreshIndicator( + onRefresh: () => _loadTasks(refresh: true), + child: _gridItems.isEmpty && !_loading + ? SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: constraints.maxHeight - 100, + child: Center( + child: Text( + 'No images yet', + style: TextStyle( + color: AppColors.textSecondary, + ), + ), + ), + ), + ) + : GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + controller: _scrollController, + padding: EdgeInsets.fromLTRB( + AppSpacing.screenPadding, + AppSpacing.xl, + AppSpacing.screenPadding, + AppSpacing.screenPaddingLarge + + (_loadingMore ? 48.0 : 0), + ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 165 / 248, + mainAxisSpacing: AppSpacing.xl, + crossAxisSpacing: AppSpacing.xl, + ), + itemCount: + _gridItems.length + (_loadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _gridItems.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } + return _GalleryCard( + mediaItem: _gridItems[index], + onTap: () { + Navigator.of(context).pushNamed( + '/result', + arguments: _gridItems[index], + ); + }, + ); + }, + ), + ), + ), + ); + }, + ), + ); + } +} + +class _GalleryCard extends StatelessWidget { + const _GalleryCard({ + required this.mediaItem, + required this.onTap, + }); + + final GalleryMediaItem mediaItem; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: LayoutBuilder( builder: (context, constraints) { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 390), - child: GridView.builder( - padding: const EdgeInsets.fromLTRB( - AppSpacing.screenPadding, - AppSpacing.xl, - AppSpacing.screenPadding, - AppSpacing.screenPaddingLarge, + return Container( + width: constraints.maxWidth, + height: constraints.maxHeight, + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppColors.border, width: 1), + boxShadow: [ + BoxShadow( + color: AppColors.shadowMedium, + blurRadius: 12, + offset: const Offset(0, 4), ), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 165 / 248, - mainAxisSpacing: AppSpacing.xl, - crossAxisSpacing: AppSpacing.xl, - ), - itemCount: _galleryImages.length, - itemBuilder: (context, index) => - _GalleryCard(imageUrl: _galleryImages[index]), - ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: mediaItem.imageUrl != null + ? CachedNetworkImage( + imageUrl: mediaItem.imageUrl!, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, + ), + ) + : _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!), ), ); }, ), ); } - } -class _GalleryCard extends StatelessWidget { - const _GalleryCard({required this.imageUrl}); +class _VideoThumbnailCover extends StatelessWidget { + const _VideoThumbnailCover({required this.videoUrl}); - final String imageUrl; + final String videoUrl; @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { + return FutureBuilder( + future: VideoThumbnailCache.instance.getThumbnail(videoUrl), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return Image.memory( + snapshot.data!, + fit: BoxFit.cover, + ); + } return Container( - width: constraints.maxWidth, - height: constraints.maxHeight, - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.border, width: 1), - boxShadow: [ - BoxShadow( - color: AppColors.shadowMedium, - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, - ), - ), - ), + color: AppColors.surfaceAlt, + child: snapshot.connectionState == ConnectionState.waiting + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const SizedBox.shrink(), ); }, ); diff --git a/lib/features/gallery/models/gallery_task_item.dart b/lib/features/gallery/models/gallery_task_item.dart new file mode 100644 index 0000000..8db92f9 --- /dev/null +++ b/lib/features/gallery/models/gallery_task_item.dart @@ -0,0 +1,55 @@ +/// 媒体项:digitize=图片URL,reconfigure=视频URL(需生成封面) +class GalleryMediaItem { + const GalleryMediaItem({ + this.imageUrl, + this.videoUrl, + }) : assert(imageUrl != null || videoUrl != null); + + final String? imageUrl; // digitize + final String? videoUrl; // reconfigure - 视频地址,用于生成封面 + + bool get isVideo => videoUrl != null && (imageUrl == null || imageUrl!.isEmpty); +} + +/// 我的任务项(V2 字段映射) +class GalleryTaskItem { + const GalleryTaskItem({ + required this.taskId, + required this.state, + required this.taskType, + required this.createTime, + required this.mediaItems, + }); + + final int taskId; + final String state; + final int taskType; + final int createTime; + final List mediaItems; + + factory GalleryTaskItem.fromJson(Map json) { + final downsample = json['downsample'] as List? ?? []; + final items = []; + for (final item in downsample) { + if (item is String) { + items.add(GalleryMediaItem(imageUrl: item)); + } else if (item is Map) { + final digitize = item['digitize'] as String?; + final reconfigure = item['reconfigure'] as String?; + // digitize=图片, reconfigure=视频;优先用图片,否则用视频生成封面 + if (digitize != null && digitize.isNotEmpty) { + items.add(GalleryMediaItem(imageUrl: digitize)); + } else if (reconfigure != null && reconfigure.isNotEmpty) { + items.add(GalleryMediaItem(videoUrl: reconfigure)); + } + } + } + return GalleryTaskItem( + taskId: (json['tree'] as num?)?.toInt() ?? 0, + state: json['listing']?.toString() ?? '', + taskType: (json['cipher'] as num?)?.toInt() ?? 0, + createTime: (json['discover'] as num?)?.toInt() ?? 0, + mediaItems: items, + ); + } +} diff --git a/lib/features/gallery/video_thumbnail_cache.dart b/lib/features/gallery/video_thumbnail_cache.dart new file mode 100644 index 0000000..07c4dee --- /dev/null +++ b/lib/features/gallery/video_thumbnail_cache.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +/// 视频封面缓存:优先从本地读取,未命中则生成并缓存 +class VideoThumbnailCache { + VideoThumbnailCache._(); + static final VideoThumbnailCache _instance = VideoThumbnailCache._(); + static VideoThumbnailCache get instance => _instance; + + static const int _maxWidth = 400; + static const int _quality = 75; + + Future getThumbnail(String videoUrl) async { + final key = _cacheKey(videoUrl); + final cacheDir = await _getCacheDir(); + final file = File('${cacheDir.path}/$key.jpg'); + + if (await file.exists()) { + return file.readAsBytes(); + } + + try { + final path = await VideoThumbnail.thumbnailFile( + video: videoUrl, + thumbnailPath: cacheDir.path, + imageFormat: ImageFormat.JPEG, + maxWidth: _maxWidth, + quality: _quality, + ); + if (path != null) { + final cached = File(path); + final bytes = await cached.readAsBytes(); + if (cached.path != file.path) { + await file.writeAsBytes(bytes); + cached.deleteSync(); + } + return bytes; + } + } catch (_) {} + return null; + } + + String _cacheKey(String url) { + final bytes = utf8.encode(url); + final digest = md5.convert(bytes); + return digest.toString(); + } + + Directory? _cacheDir; + Future _getCacheDir() async { + _cacheDir ??= await getTemporaryDirectory(); + final dir = Directory('${_cacheDir!.path}/video_thumbnails'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } +} diff --git a/lib/features/generate_video/generation_result_screen.dart b/lib/features/generate_video/generation_result_screen.dart index 2cd15de..4fdc74f 100644 --- a/lib/features/generate_video/generation_result_screen.dart +++ b/lib/features/generate_video/generation_result_screen.dart @@ -1,13 +1,125 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; +import 'package:gal/gal.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:video_player/video_player.dart'; + import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; +import '../../features/gallery/models/gallery_task_item.dart'; import '../../shared/widgets/top_nav_bar.dart'; /// Video Generation Result screen - matches Pencil cFA4T -class GenerationResultScreen extends StatelessWidget { - const GenerationResultScreen({super.key}); +class GenerationResultScreen extends StatefulWidget { + const GenerationResultScreen({super.key, this.mediaItem}); + + final GalleryMediaItem? mediaItem; + + @override + State createState() => _GenerationResultScreenState(); +} + +class _GenerationResultScreenState extends State { + VideoPlayerController? _videoController; + bool _saving = false; + bool _videoLoading = true; + String? _videoLoadError; + + String? get _videoUrl => widget.mediaItem?.videoUrl; + String? get _imageUrl => widget.mediaItem?.imageUrl; + + @override + void initState() { + super.initState(); + if (_videoUrl != null) { + _initVideoFromCache(); + } + } + + Future _initVideoFromCache() async { + if (_videoUrl == null) return; + try { + final file = await DefaultCacheManager().getSingleFile(_videoUrl!); + if (!mounted) return; + final controller = VideoPlayerController.file(file); + await controller.initialize(); + if (!mounted) return; + setState(() { + _videoController = controller; + _videoLoading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _videoLoading = false; + _videoLoadError = e.toString(); + }); + } + } + } + + @override + void dispose() { + _videoController?.dispose(); + super.dispose(); + } + + Future _saveToAlbum() async { + if (widget.mediaItem == null) return; + setState(() => _saving = true); + + try { + final hasAccess = await Gal.hasAccess(toAlbum: true); + if (!hasAccess) { + final granted = await Gal.requestAccess(toAlbum: true); + if (!granted) { + throw Exception('Photo library access denied'); + } + } + + if (_videoUrl != null) { + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/gallery_video_${DateTime.now().millisecondsSinceEpoch}.mp4'); + final response = await http.get(Uri.parse(_videoUrl!)); + if (response.statusCode != 200) { + throw Exception('Failed to download video'); + } + await file.writeAsBytes(response.bodyBytes); + await Gal.putVideo(file.path); + await file.delete(); + } else if (_imageUrl != null) { + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/gallery_image_${DateTime.now().millisecondsSinceEpoch}.jpg'); + final response = await http.get(Uri.parse(_imageUrl!)); + if (response.statusCode != 200) { + throw Exception('Failed to download image'); + } + await file.writeAsBytes(response.bodyBytes); + await Gal.putImage(file.path); + await file.delete(); + } + + if (mounted) { + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Saved to photo library')), + ); + } + } catch (e) { + if (mounted) { + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Save failed: $e')), + ); + } + } + } @override Widget build(BuildContext context) { @@ -21,24 +133,54 @@ class GenerationResultScreen extends StatelessWidget { onBack: () => Navigator.of(context).pop(), ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _VideoDisplay(), - const SizedBox(height: AppSpacing.xxl), - _DownloadButton(onDownload: () {}), - const SizedBox(height: AppSpacing.lg), - _ShareButton(onShare: () {}), - ], - ), - ), + body: widget.mediaItem == null + ? Center( + child: Text( + 'No media', + style: TextStyle(color: AppColors.textSecondary), + ), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MediaDisplay( + videoUrl: _videoUrl, + imageUrl: _imageUrl, + videoController: _videoController, + videoLoading: _videoUrl != null ? _videoLoading : false, + videoLoadError: _videoLoadError, + ), + const SizedBox(height: AppSpacing.xxl), + _DownloadButton( + onDownload: _saving ? null : _saveToAlbum, + saving: _saving, + ), + const SizedBox(height: AppSpacing.lg), + _ShareButton(onShare: () {}), + ], + ), + ), ); } } -class _VideoDisplay extends StatelessWidget { +class _MediaDisplay extends StatelessWidget { + const _MediaDisplay({ + this.videoUrl, + this.imageUrl, + this.videoController, + this.videoLoading = false, + this.videoLoadError, + }); + + final String? videoUrl; + final String? imageUrl; + final VideoPlayerController? videoController; + final bool videoLoading; + final String? videoLoadError; + @override Widget build(BuildContext context) { return Container( @@ -48,6 +190,131 @@ class _VideoDisplay extends StatelessWidget { borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border, width: 1), ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: videoUrl != null && videoController != null + ? _VideoPlayer( + controller: videoController!, + ) + : videoUrl != null && videoLoading + ? Container( + color: AppColors.textPrimary, + alignment: Alignment.center, + child: const CircularProgressIndicator( + color: AppColors.surface, + ), + ) + : videoUrl != null && videoLoadError != null + ? Container( + color: AppColors.textPrimary, + alignment: Alignment.center, + child: Text( + 'Load failed', + style: TextStyle(color: AppColors.surface), + ), + ) + : imageUrl != null + ? CachedNetworkImage( + imageUrl: imageUrl!, + fit: BoxFit.cover, + placeholder: (_, __) => _Placeholder(), + errorWidget: (_, __, ___) => _Placeholder(), + ) + : _Placeholder(), + ), + ); + } +} + +class _VideoPlayer extends StatefulWidget { + const _VideoPlayer({required this.controller}); + + final VideoPlayerController controller; + + @override + State<_VideoPlayer> createState() => _VideoPlayerState(); +} + +class _VideoPlayerState extends State<_VideoPlayer> { + @override + void initState() { + super.initState(); + widget.controller.addListener(_listener); + } + + @override + void dispose() { + widget.controller.removeListener(_listener); + super.dispose(); + } + + void _listener() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (!widget.controller.value.isInitialized) { + return Container( + color: AppColors.textPrimary, + child: const Center( + child: CircularProgressIndicator(color: AppColors.surface), + ), + ); + } + + return Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: widget.controller.value.size.width > 0 + ? widget.controller.value.size.width + : 16, + height: widget.controller.value.size.height > 0 + ? widget.controller.value.size.height + : 9, + child: VideoPlayer(widget.controller), + ), + ), + Center( + child: GestureDetector( + onTap: () { + if (widget.controller.value.isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + }, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + child: Icon( + widget.controller.value.isPlaying + ? LucideIcons.pause + : LucideIcons.play, + size: 32, + color: AppColors.textPrimary, + ), + ), + ), + ), + ], + ); + } +} + +class _Placeholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + color: AppColors.textPrimary, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -70,9 +337,13 @@ class _VideoDisplay extends StatelessWidget { } class _DownloadButton extends StatelessWidget { - const _DownloadButton({required this.onDownload}); + const _DownloadButton({ + required this.onDownload, + this.saving = false, + }); - final VoidCallback onDownload; + final VoidCallback? onDownload; + final bool saving; @override Widget build(BuildContext context) { @@ -81,23 +352,35 @@ class _DownloadButton extends StatelessWidget { child: Container( height: 52, decoration: BoxDecoration( - color: AppColors.primary, + color: saving ? AppColors.surfaceAlt : AppColors.primary, borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppColors.primaryShadow.withValues(alpha: 0.25), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], + boxShadow: saving + ? null + : [ + BoxShadow( + color: AppColors.primaryShadow.withValues(alpha: 0.25), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(LucideIcons.download, size: 20, color: AppColors.surface), + if (saving) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textSecondary, + ), + ) + else + Icon(LucideIcons.download, size: 20, color: AppColors.surface), const SizedBox(width: AppSpacing.md), Text( - 'Download', + saving ? 'Saving...' : 'Save to Album', style: AppTypography.bodyMedium.copyWith( color: AppColors.surface, ), diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index 5c67976..f30852d 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:video_player/video_player.dart'; @@ -35,7 +36,6 @@ class VideoCard extends StatefulWidget { class _VideoCardState extends State { VideoPlayerController? _controller; bool _isLoading = false; - String? _loadError; @override void initState() { @@ -65,10 +65,7 @@ class _VideoCardState extends State { _controller?.removeListener(_onVideoUpdate); _controller?.pause(); if (mounted) { - setState(() { - _isLoading = false; - _loadError = null; - }); + setState(() => _isLoading = false); } } @@ -97,15 +94,15 @@ class _VideoCardState extends State { return; } - setState(() { - _isLoading = true; - _loadError = null; - }); + setState(() => _isLoading = true); - _controller = VideoPlayerController.networkUrl( - Uri.parse(widget.videoUrl!), - ); try { + final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!); + if (!mounted || !widget.isActive) { + setState(() => _isLoading = false); + return; + } + _controller = VideoPlayerController.file(file); await _controller!.initialize(); _controller!.addListener(_onVideoUpdate); if (mounted && widget.isActive) { @@ -115,10 +112,7 @@ class _VideoCardState extends State { } catch (e) { if (mounted) { _disposeController(); - setState(() { - _isLoading = false; - _loadError = e.toString(); - }); + setState(() => _isLoading = false); widget.onStopRequested(); } } diff --git a/pubspec.yaml b/pubspec.yaml index 139226d..a802282 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,10 @@ dependencies: http: ^1.2.2 image_picker: ^1.0.7 video_player: ^2.9.2 + video_thumbnail: ^0.5.3 + gal: ^2.3.0 + path_provider: ^2.1.2 + flutter_cache_manager: ^3.3.1 dev_dependencies: flutter_test: