新增:我的画廊数据对接,视频缓存
This commit is contained in:
parent
9ced4f24e7
commit
e34f912744
@ -1,5 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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
|
<application
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||||
android:label="AI创作"
|
android:label="AI创作"
|
||||||
|
|||||||
@ -18,6 +18,12 @@
|
|||||||
@import device_info_plus;
|
@import device_info_plus;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<gal/GalPlugin.h>)
|
||||||
|
#import <gal/GalPlugin.h>
|
||||||
|
#else
|
||||||
|
@import gal;
|
||||||
|
#endif
|
||||||
|
|
||||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||||
#else
|
#else
|
||||||
@ -36,14 +42,22 @@
|
|||||||
@import video_player_avfoundation;
|
@import video_player_avfoundation;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<video_thumbnail/VideoThumbnailPlugin.h>)
|
||||||
|
#import <video_thumbnail/VideoThumbnailPlugin.h>
|
||||||
|
#else
|
||||||
|
@import video_thumbnail;
|
||||||
|
#endif
|
||||||
|
|
||||||
@implementation GeneratedPluginRegistrant
|
@implementation GeneratedPluginRegistrant
|
||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
|
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
|
||||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||||
|
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||||
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
||||||
|
[VideoThumbnailPlugin registerWithRegistrar:[registry registrarForPlugin:@"VideoThumbnailPlugin"]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
<string>MagiEvery AI</string>
|
<string>MagiEvery AI</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>We need access to your photos to use as reference for AI image generation.</string>
|
<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>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>We need camera access to capture reference images for AI generation.</string>
|
<string>We need camera access to capture reference images for AI generation.</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
|||||||
19
lib/app.dart
19
lib/app.dart
@ -5,6 +5,7 @@ import 'core/user/user_state.dart';
|
|||||||
import 'features/gallery/gallery_screen.dart';
|
import 'features/gallery/gallery_screen.dart';
|
||||||
import 'features/generate_video/generate_progress_screen.dart';
|
import 'features/generate_video/generate_progress_screen.dart';
|
||||||
import 'features/generate_video/generate_video_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/generate_video/generation_result_screen.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
import 'features/home/models/task_item.dart';
|
import 'features/home/models/task_item.dart';
|
||||||
@ -54,7 +55,11 @@ class _AppState extends State<App> {
|
|||||||
final taskId = ModalRoute.of(ctx)?.settings.arguments;
|
final taskId = ModalRoute.of(ctx)?.settings.arguments;
|
||||||
return GenerateProgressScreen(taskId: taskId);
|
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 NavTab currentTab;
|
||||||
final ValueChanged<NavTab> onTabSelected;
|
final ValueChanged<NavTab> onTabSelected;
|
||||||
|
|
||||||
static const _screens = [
|
|
||||||
HomeScreen(),
|
|
||||||
GalleryScreen(),
|
|
||||||
ProfileScreen(),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: currentTab.index,
|
index: currentTab.index,
|
||||||
children: _screens,
|
children: [
|
||||||
|
const HomeScreen(),
|
||||||
|
GalleryScreen(isActive: currentTab == NavTab.gallery),
|
||||||
|
const ProfileScreen(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
currentTab: currentTab,
|
currentTab: currentTab,
|
||||||
|
|||||||
@ -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({
|
static Future<ApiResponse> getCreditsPageInfo({
|
||||||
required String sentinel,
|
required String sentinel,
|
||||||
|
|||||||
@ -1,21 +1,139 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/theme/app_colors.dart';
|
||||||
import '../../core/user/user_state.dart';
|
|
||||||
import '../../core/theme/app_spacing.dart';
|
import '../../core/theme/app_spacing.dart';
|
||||||
|
import '../../core/user/user_state.dart';
|
||||||
import '../../shared/widgets/top_nav_bar.dart';
|
import '../../shared/widgets/top_nav_bar.dart';
|
||||||
|
|
||||||
/// Gallery screen - matches Pencil hpwBg
|
import 'models/gallery_task_item.dart';
|
||||||
class GalleryScreen extends StatelessWidget {
|
import 'video_thumbnail_cache.dart';
|
||||||
const GalleryScreen({super.key});
|
|
||||||
|
|
||||||
static const _galleryImages = [
|
/// Gallery screen - matches Pencil hpwBg
|
||||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400',
|
class GalleryScreen extends StatefulWidget {
|
||||||
'https://images.unsplash.com/photo-1703592819695-ea63799b7315?w=400',
|
const GalleryScreen({super.key, required this.isActive});
|
||||||
'https://images.unsplash.com/photo-1764787435677-1321e12559e3?w=400',
|
|
||||||
'https://images.unsplash.com/photo-1759264244741-7175af0b7e75?w=400',
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -29,75 +147,181 @@ class GalleryScreen extends StatelessWidget {
|
|||||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
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) {
|
builder: (context, constraints) {
|
||||||
return Center(
|
return Container(
|
||||||
child: ConstrainedBox(
|
width: constraints.maxWidth,
|
||||||
constraints: const BoxConstraints(maxWidth: 390),
|
height: constraints.maxHeight,
|
||||||
child: GridView.builder(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
color: AppColors.surfaceAlt,
|
||||||
AppSpacing.screenPadding,
|
borderRadius: BorderRadius.circular(24),
|
||||||
AppSpacing.xl,
|
border: Border.all(color: AppColors.border, width: 1),
|
||||||
AppSpacing.screenPadding,
|
boxShadow: [
|
||||||
AppSpacing.screenPaddingLarge,
|
BoxShadow(
|
||||||
|
color: AppColors.shadowMedium,
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
gridDelegate:
|
],
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
),
|
||||||
crossAxisCount: 2,
|
child: ClipRRect(
|
||||||
childAspectRatio: 165 / 248,
|
borderRadius: BorderRadius.circular(24),
|
||||||
mainAxisSpacing: AppSpacing.xl,
|
child: mediaItem.imageUrl != null
|
||||||
crossAxisSpacing: AppSpacing.xl,
|
? CachedNetworkImage(
|
||||||
),
|
imageUrl: mediaItem.imageUrl!,
|
||||||
itemCount: _galleryImages.length,
|
fit: BoxFit.cover,
|
||||||
itemBuilder: (context, index) =>
|
placeholder: (_, __) => Container(
|
||||||
_GalleryCard(imageUrl: _galleryImages[index]),
|
color: AppColors.surfaceAlt,
|
||||||
),
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: AppColors.surfaceAlt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GalleryCard extends StatelessWidget {
|
class _VideoThumbnailCover extends StatelessWidget {
|
||||||
const _GalleryCard({required this.imageUrl});
|
const _VideoThumbnailCover({required this.videoUrl});
|
||||||
|
|
||||||
final String imageUrl;
|
final String videoUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return FutureBuilder<Uint8List?>(
|
||||||
builder: (context, constraints) {
|
future: VideoThumbnailCache.instance.getThumbnail(videoUrl),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData && snapshot.data != null) {
|
||||||
|
return Image.memory(
|
||||||
|
snapshot.data!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
);
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
width: constraints.maxWidth,
|
color: AppColors.surfaceAlt,
|
||||||
height: constraints.maxHeight,
|
child: snapshot.connectionState == ConnectionState.waiting
|
||||||
decoration: BoxDecoration(
|
? const Center(
|
||||||
color: AppColors.surfaceAlt,
|
child: SizedBox(
|
||||||
borderRadius: BorderRadius.circular(24),
|
width: 24,
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
height: 24,
|
||||||
boxShadow: [
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
BoxShadow(
|
),
|
||||||
color: AppColors.shadowMedium,
|
)
|
||||||
blurRadius: 12,
|
: const SizedBox.shrink(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
55
lib/features/gallery/models/gallery_task_item.dart
Normal file
55
lib/features/gallery/models/gallery_task_item.dart
Normal file
@ -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<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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/features/gallery/video_thumbnail_cache.dart
Normal file
63
lib/features/gallery/video_thumbnail_cache.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,125 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.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_colors.dart';
|
||||||
import '../../core/theme/app_spacing.dart';
|
import '../../core/theme/app_spacing.dart';
|
||||||
import '../../core/theme/app_typography.dart';
|
import '../../core/theme/app_typography.dart';
|
||||||
|
import '../../features/gallery/models/gallery_task_item.dart';
|
||||||
import '../../shared/widgets/top_nav_bar.dart';
|
import '../../shared/widgets/top_nav_bar.dart';
|
||||||
|
|
||||||
/// Video Generation Result screen - matches Pencil cFA4T
|
/// Video Generation Result screen - matches Pencil cFA4T
|
||||||
class GenerationResultScreen extends StatelessWidget {
|
class GenerationResultScreen extends StatefulWidget {
|
||||||
const GenerationResultScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -21,24 +133,54 @@ class GenerationResultScreen extends StatelessWidget {
|
|||||||
onBack: () => Navigator.of(context).pop(),
|
onBack: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: widget.mediaItem == null
|
||||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
? Center(
|
||||||
child: Column(
|
child: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
'No media',
|
||||||
children: [
|
style: TextStyle(color: AppColors.textSecondary),
|
||||||
_VideoDisplay(),
|
),
|
||||||
const SizedBox(height: AppSpacing.xxl),
|
)
|
||||||
_DownloadButton(onDownload: () {}),
|
: SingleChildScrollView(
|
||||||
const SizedBox(height: AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||||
_ShareButton(onShare: () {}),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@ -48,6 +190,131 @@ class _VideoDisplay extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border, width: 1),
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@ -70,9 +337,13 @@ class _VideoDisplay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadButton 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -81,23 +352,35 @@ class _DownloadButton extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
height: 52,
|
height: 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary,
|
color: saving ? AppColors.surfaceAlt : AppColors.primary,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
boxShadow: [
|
boxShadow: saving
|
||||||
BoxShadow(
|
? null
|
||||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
: [
|
||||||
blurRadius: 6,
|
BoxShadow(
|
||||||
offset: const Offset(0, 2),
|
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||||
),
|
blurRadius: 6,
|
||||||
],
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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),
|
const SizedBox(width: AppSpacing.md),
|
||||||
Text(
|
Text(
|
||||||
'Download',
|
saving ? 'Saving...' : 'Save to Album',
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
@ -35,7 +36,6 @@ class VideoCard extends StatefulWidget {
|
|||||||
class _VideoCardState extends State<VideoCard> {
|
class _VideoCardState extends State<VideoCard> {
|
||||||
VideoPlayerController? _controller;
|
VideoPlayerController? _controller;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _loadError;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -65,10 +65,7 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
_controller?.removeListener(_onVideoUpdate);
|
_controller?.removeListener(_onVideoUpdate);
|
||||||
_controller?.pause();
|
_controller?.pause();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() => _isLoading = false);
|
||||||
_isLoading = false;
|
|
||||||
_loadError = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,15 +94,15 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() => _isLoading = true);
|
||||||
_isLoading = true;
|
|
||||||
_loadError = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
_controller = VideoPlayerController.networkUrl(
|
|
||||||
Uri.parse(widget.videoUrl!),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
|
final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!);
|
||||||
|
if (!mounted || !widget.isActive) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_controller = VideoPlayerController.file(file);
|
||||||
await _controller!.initialize();
|
await _controller!.initialize();
|
||||||
_controller!.addListener(_onVideoUpdate);
|
_controller!.addListener(_onVideoUpdate);
|
||||||
if (mounted && widget.isActive) {
|
if (mounted && widget.isActive) {
|
||||||
@ -115,10 +112,7 @@ class _VideoCardState extends State<VideoCard> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_disposeController();
|
_disposeController();
|
||||||
setState(() {
|
setState(() => _isLoading = false);
|
||||||
_isLoading = false;
|
|
||||||
_loadError = e.toString();
|
|
||||||
});
|
|
||||||
widget.onStopRequested();
|
widget.onStopRequested();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,10 @@ dependencies:
|
|||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
video_player: ^2.9.2
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user