新增:我的画廊数据对接,视频缓存

This commit is contained in:
ivan 2026-03-09 22:23:31 +08:00
parent 9ced4f24e7
commit e34f912744
11 changed files with 779 additions and 116 deletions

View File

@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:label="AI创作"

View File

@ -18,6 +18,12 @@
@import device_info_plus;
#endif
#if __has_include(<gal/GalPlugin.h>)
#import <gal/GalPlugin.h>
#else
@import gal;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@ -36,14 +42,22 @@
@import video_player_avfoundation;
#endif
#if __has_include(<video_thumbnail/VideoThumbnailPlugin.h>)
#import <video_thumbnail/VideoThumbnailPlugin.h>
#else
@import video_thumbnail;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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

View File

@ -8,6 +8,8 @@
<string>MagiEvery AI</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your photos to use as reference for AI image generation.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need permission to save videos and images to your photo library.</string>
<key>NSCameraUsageDescription</key>
<string>We need camera access to capture reference images for AI generation.</string>
<key>CFBundleExecutable</key>

View File

@ -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<App> {
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<NavTab> 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,

View File

@ -175,6 +175,25 @@ abstract final class ImageApi {
);
}
///
static Future<ApiResponse> 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<ApiResponse> getCreditsPageInfo({
required String sentinel,

View File

@ -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<GalleryScreen> createState() => _GalleryScreenState();
}
class _GalleryScreenState extends State<GalleryScreen> {
List<GalleryTaskItem> _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<void> _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<String, dynamic>?;
final intensify = data?['intensify'] as List<dynamic>? ?? [];
final list = intensify
.whereType<Map<String, dynamic>>()
.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<GalleryMediaItem> get _gridItems {
final items = <GalleryMediaItem>[];
for (final task in _tasks) {
items.addAll(task.mediaItems);
}
return items;
}
@override
Widget build(BuildContext context) {
@ -29,17 +147,57 @@ 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: GridView.builder(
padding: const EdgeInsets.fromLTRB(
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,
AppSpacing.screenPaddingLarge +
(_loadingMore ? 48.0 : 0),
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
@ -48,9 +206,34 @@ class GalleryScreen extends StatelessWidget {
mainAxisSpacing: AppSpacing.xl,
crossAxisSpacing: AppSpacing.xl,
),
itemCount: _galleryImages.length,
itemBuilder: (context, index) =>
_GalleryCard(imageUrl: _galleryImages[index]),
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],
);
},
);
},
),
),
),
);
@ -58,17 +241,22 @@ class GalleryScreen extends StatelessWidget {
),
);
}
}
class _GalleryCard extends StatelessWidget {
const _GalleryCard({required this.imageUrl});
const _GalleryCard({
required this.mediaItem,
required this.onTap,
});
final String imageUrl;
final GalleryMediaItem mediaItem;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
return GestureDetector(
onTap: onTap,
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
width: constraints.maxWidth,
@ -87,8 +275,9 @@ class _GalleryCard extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: CachedNetworkImage(
imageUrl: imageUrl,
child: mediaItem.imageUrl != null
? CachedNetworkImage(
imageUrl: mediaItem.imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: AppColors.surfaceAlt,
@ -96,9 +285,44 @@ class _GalleryCard extends StatelessWidget {
errorWidget: (_, __, ___) => Container(
color: AppColors.surfaceAlt,
),
)
: _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!),
),
);
},
),
);
}
}
class _VideoThumbnailCover extends StatelessWidget {
const _VideoThumbnailCover({required this.videoUrl});
final String videoUrl;
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: VideoThumbnailCache.instance.getThumbnail(videoUrl),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
fit: BoxFit.cover,
);
}
return Container(
color: AppColors.surfaceAlt,
child: snapshot.connectionState == ConnectionState.waiting
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const SizedBox.shrink(),
);
},
);
}

View File

@ -0,0 +1,55 @@
/// digitize=URLreconfigure=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<GalleryMediaItem> mediaItems;
factory GalleryTaskItem.fromJson(Map<String, dynamic> json) {
final downsample = json['downsample'] as List<dynamic>? ?? [];
final items = <GalleryMediaItem>[];
for (final item in downsample) {
if (item is String) {
items.add(GalleryMediaItem(imageUrl: item));
} else if (item is Map<String, dynamic>) {
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,
);
}
}

View File

@ -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<Uint8List?> 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<Directory> _getCacheDir() async {
_cacheDir ??= await getTemporaryDirectory();
final dir = Directory('${_cacheDir!.path}/video_thumbnails');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
}

View File

@ -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<GenerationResultScreen> createState() => _GenerationResultScreenState();
}
class _GenerationResultScreenState extends State<GenerationResultScreen> {
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<void> _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<void> _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,14 +133,30 @@ class GenerationResultScreen extends StatelessWidget {
onBack: () => Navigator.of(context).pop(),
),
),
body: SingleChildScrollView(
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: [
_VideoDisplay(),
_MediaDisplay(
videoUrl: _videoUrl,
imageUrl: _imageUrl,
videoController: _videoController,
videoLoading: _videoUrl != null ? _videoLoading : false,
videoLoadError: _videoLoadError,
),
const SizedBox(height: AppSpacing.xxl),
_DownloadButton(onDownload: () {}),
_DownloadButton(
onDownload: _saving ? null : _saveToAlbum,
saving: _saving,
),
const SizedBox(height: AppSpacing.lg),
_ShareButton(onShare: () {}),
],
@ -38,7 +166,21 @@ class GenerationResultScreen extends StatelessWidget {
}
}
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,9 +352,11 @@ 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: saving
? null
: [
BoxShadow(
color: AppColors.primaryShadow.withValues(alpha: 0.25),
blurRadius: 6,
@ -94,10 +367,20 @@ class _DownloadButton extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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,
),

View File

@ -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<VideoCard> {
VideoPlayerController? _controller;
bool _isLoading = false;
String? _loadError;
@override
void initState() {
@ -65,10 +65,7 @@ class _VideoCardState extends State<VideoCard> {
_controller?.removeListener(_onVideoUpdate);
_controller?.pause();
if (mounted) {
setState(() {
_isLoading = false;
_loadError = null;
});
setState(() => _isLoading = false);
}
}
@ -97,15 +94,15 @@ class _VideoCardState extends State<VideoCard> {
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<VideoCard> {
} catch (e) {
if (mounted) {
_disposeController();
setState(() {
_isLoading = false;
_loadError = e.toString();
});
setState(() => _isLoading = false);
widget.onStopRequested();
}
}

View File

@ -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: