优化:相册选择优化
This commit is contained in:
parent
a8c9a59167
commit
e86b001a27
@ -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.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
<application
|
||||
|
||||
@ -48,6 +48,12 @@
|
||||
@import in_app_purchase_storekit;
|
||||
#endif
|
||||
|
||||
#if __has_include(<photo_manager/PhotoManagerPlugin.h>)
|
||||
#import <photo_manager/PhotoManagerPlugin.h>
|
||||
#else
|
||||
@import photo_manager;
|
||||
#endif
|
||||
|
||||
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
|
||||
#import <screen_secure/ScreenSecurePlugin.h>
|
||||
#else
|
||||
@ -100,6 +106,7 @@
|
||||
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
|
||||
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
import '../../core/api/api_config.dart';
|
||||
import '../../core/api/services/image_api.dart';
|
||||
@ -9,6 +9,7 @@ import '../../core/auth/auth_service.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
import '../home/widgets/video_card.dart';
|
||||
|
||||
import 'models/gallery_task_item.dart';
|
||||
import 'video_thumbnail_cache.dart';
|
||||
@ -34,6 +35,36 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static const int _pageSize = 20;
|
||||
|
||||
/// 与首页一致:足够可见比例的格子才自动静音循环播视频(允许多个)
|
||||
static const double _videoVisibilityThreshold = 0.15;
|
||||
Set<int> _visibleVideoIndices = {};
|
||||
final Set<int> _userPausedVideoIndices = {};
|
||||
final Map<int, double> _cardVisibleFraction = {};
|
||||
bool _visibilityReconcileScheduled = false;
|
||||
|
||||
/// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow],
|
||||
/// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。
|
||||
/// 多帧 + 短延迟与数据就绪后再通知一次,与首页 [HomeScreen._scheduleVisibilityRefresh] 思路一致。
|
||||
void _scheduleGalleryVisibilityRefresh() {
|
||||
if (!mounted || !widget.isActive) return;
|
||||
void notify() {
|
||||
if (!mounted || !widget.isActive) return;
|
||||
VisibilityDetectorController.instance.notifyNow();
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
notify();
|
||||
});
|
||||
});
|
||||
});
|
||||
Future<void>.delayed(const Duration(milliseconds: 80), notify);
|
||||
Future<void>.delayed(const Duration(milliseconds: 220), notify);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -44,8 +75,11 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
@override
|
||||
void didUpdateWidget(covariant GalleryScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isActive && !oldWidget.isActive && !_hasLoadedOnce) {
|
||||
_loadTasks(refresh: true);
|
||||
if (widget.isActive && !oldWidget.isActive) {
|
||||
_scheduleGalleryVisibilityRefresh();
|
||||
if (!_hasLoadedOnce) {
|
||||
_loadTasks(refresh: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,12 +91,54 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
|
||||
void _onScroll() {
|
||||
if (_loadingMore || !_hasNext || _tasks.isEmpty) return;
|
||||
if (!_scrollController.hasClients) return;
|
||||
final pos = _scrollController.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 200) {
|
||||
_loadTasks(refresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
|
||||
if (!mounted) return;
|
||||
if (info.visibleFraction >= _videoVisibilityThreshold) {
|
||||
_cardVisibleFraction[index] = info.visibleFraction;
|
||||
} else {
|
||||
_cardVisibleFraction.remove(index);
|
||||
}
|
||||
if (_visibilityReconcileScheduled) return;
|
||||
_visibilityReconcileScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_visibilityReconcileScheduled = false;
|
||||
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
|
||||
});
|
||||
}
|
||||
|
||||
void _reconcileVisibleVideoIndicesFromDetector() {
|
||||
final items = _gridItems;
|
||||
final next = <int>{};
|
||||
for (final e in _cardVisibleFraction.entries) {
|
||||
final i = e.key;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
if (e.value < _videoVisibilityThreshold) continue;
|
||||
final url = items[i].videoUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
next.add(i);
|
||||
}
|
||||
}
|
||||
_userPausedVideoIndices.removeWhere((i) => !next.contains(i));
|
||||
if (!_setsEqual(next, _visibleVideoIndices)) {
|
||||
setState(() => _visibleVideoIndices = next);
|
||||
}
|
||||
}
|
||||
|
||||
bool _setsEqual(Set<int> a, Set<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final e in a) {
|
||||
if (!b.contains(e)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _loadTasks({bool refresh = true}) async {
|
||||
if (refresh) {
|
||||
setState(() {
|
||||
@ -97,6 +173,9 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
setState(() {
|
||||
if (refresh) {
|
||||
_tasks = list;
|
||||
_visibleVideoIndices = {};
|
||||
_userPausedVideoIndices.clear();
|
||||
_cardVisibleFraction.clear();
|
||||
} else {
|
||||
_tasks = [..._tasks, ...list];
|
||||
}
|
||||
@ -106,6 +185,9 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
_loadingMore = false;
|
||||
_hasLoadedOnce = true;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _scheduleGalleryVisibilityRefresh();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
if (refresh) _tasks = [];
|
||||
@ -222,14 +304,54 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return _GalleryCard(
|
||||
mediaItem: _gridItems[index],
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
'/result',
|
||||
arguments: _gridItems[index],
|
||||
);
|
||||
},
|
||||
final media = _gridItems[index];
|
||||
final videoUrl = media.videoUrl;
|
||||
final hasVideo =
|
||||
videoUrl != null && videoUrl.isNotEmpty;
|
||||
final detectorKey =
|
||||
'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}';
|
||||
void openResult() {
|
||||
Navigator.of(context).pushNamed(
|
||||
'/result',
|
||||
arguments: media,
|
||||
);
|
||||
}
|
||||
|
||||
return VisibilityDetector(
|
||||
key: ValueKey(detectorKey),
|
||||
onVisibilityChanged: hasVideo
|
||||
? (info) =>
|
||||
_onGridCardVisibilityChanged(
|
||||
index, info)
|
||||
: (_) {},
|
||||
child: VideoCard(
|
||||
key: ValueKey(detectorKey),
|
||||
imageUrl: media.imageUrl ??
|
||||
videoUrl ??
|
||||
'',
|
||||
cover: hasVideo
|
||||
? _VideoThumbnailCover(
|
||||
videoUrl: media.videoUrl!,
|
||||
)
|
||||
: null,
|
||||
videoUrl: hasVideo ? videoUrl : null,
|
||||
credits: '50',
|
||||
showCreditsBadge: false,
|
||||
showBottomGenerateButton: false,
|
||||
isActive: widget.isActive &&
|
||||
hasVideo &&
|
||||
_visibleVideoIndices
|
||||
.contains(index) &&
|
||||
!_userPausedVideoIndices
|
||||
.contains(index),
|
||||
onPlayRequested: () => setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.remove(index)),
|
||||
onStopRequested: () => setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.add(index)),
|
||||
onGenerateSimilar: openResult,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -242,61 +364,6 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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 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: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowMedium,
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: mediaItem.isVideo
|
||||
? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!)
|
||||
: (mediaItem.imageUrl != null &&
|
||||
mediaItem.imageUrl!.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: mediaItem.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
)
|
||||
: Container(color: AppColors.surfaceAlt),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoThumbnailCover extends StatelessWidget {
|
||||
const _VideoThumbnailCover({required this.videoUrl});
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../core/auth/auth_service.dart';
|
||||
@ -19,6 +18,7 @@ import '../../features/home/models/task_item.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
import '../../core/api/services/image_api.dart';
|
||||
import 'widgets/album_picker_sheet.dart';
|
||||
|
||||
/// Generate Video screen - matches Pencil mmLB5
|
||||
class GenerateVideoScreen extends StatefulWidget {
|
||||
@ -79,36 +79,18 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
final path = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.image),
|
||||
title: const Text('Choose from gallery'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.camera),
|
||||
title: const Text('Take photo'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
],
|
||||
),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => SizedBox(
|
||||
height: MediaQuery.sizeOf(ctx).height * 0.92,
|
||||
child: const AlbumPickerSheet(),
|
||||
),
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
if (path == null || path.isEmpty || !mounted) return;
|
||||
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
final file = File(picked.path);
|
||||
final file = File(path);
|
||||
await _runGenerationApi(file);
|
||||
}
|
||||
|
||||
|
||||
@ -52,6 +52,9 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
||||
final controller = VideoPlayerController.file(file);
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
await controller.setLooping(true);
|
||||
await controller.play();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_videoController = controller;
|
||||
_videoLoading = false;
|
||||
|
||||
401
lib/features/generate_video/widgets/album_picker_sheet.dart
Normal file
401
lib/features/generate_video/widgets/album_picker_sheet.dart
Normal file
@ -0,0 +1,401 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
|
||||
/// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致)
|
||||
class AlbumPickerSheet extends StatefulWidget {
|
||||
const AlbumPickerSheet({super.key});
|
||||
|
||||
@override
|
||||
State<AlbumPickerSheet> createState() => _AlbumPickerSheetState();
|
||||
}
|
||||
|
||||
class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
/// 首屏少一点,减轻同时解码缩略图的压力
|
||||
static const int _pageSize = 36;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool _busy = true;
|
||||
String? _error;
|
||||
AssetPathEntity? _recentAlbum;
|
||||
final List<AssetEntity> _assets = [];
|
||||
int _loadedPage = -1;
|
||||
int _totalCount = 0;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_maybeLoadMore);
|
||||
// 等弹层完成首帧布局后再跑权限/读库,避免与 ModalRoute 争主线程;也方便 ScrollController 晚一点 attach
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _init();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_maybeLoadMore);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final state = await PhotoManager.requestPermissionExtend();
|
||||
if (!mounted) return;
|
||||
|
||||
if (!state.hasAccess) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = state == PermissionState.denied
|
||||
? 'Photo library access is required. Tap below to open Settings.'
|
||||
: 'Cannot access photo library.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final paths = await PhotoManager.getAssetPathList(
|
||||
type: RequestType.image,
|
||||
onlyAll: true,
|
||||
hasAll: true,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (paths.isEmpty) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_recentAlbum = null;
|
||||
_totalCount = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_recentAlbum = paths.first;
|
||||
_totalCount = await _recentAlbum!.assetCountAsync;
|
||||
final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_assets
|
||||
..clear()
|
||||
..addAll(first);
|
||||
_loadedPage = 0;
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _maybeLoadMore() {
|
||||
if (_loadingMore || _busy || _recentAlbum == null) return;
|
||||
if (_assets.length >= _totalCount) return;
|
||||
// 未 attach 时访问 .position 会抛错,导致点击 Generate 后卡死/闪退
|
||||
if (!_scrollController.hasClients) return;
|
||||
final pos = _scrollController.position;
|
||||
final maxExtent = pos.maxScrollExtent;
|
||||
if (!maxExtent.isFinite || maxExtent <= 0) return;
|
||||
if (pos.pixels < maxExtent - 380) return;
|
||||
_loadMore();
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
final album = _recentAlbum;
|
||||
if (album == null || _loadingMore) return;
|
||||
if (_assets.length >= _totalCount) return;
|
||||
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
final nextPage = _loadedPage + 1;
|
||||
final list =
|
||||
await album.getAssetListPaged(page: nextPage, size: _pageSize);
|
||||
if (!mounted) return;
|
||||
if (list.isNotEmpty) {
|
||||
setState(() {
|
||||
_assets.addAll(list);
|
||||
_loadedPage = nextPage;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCamera() async {
|
||||
final picker = ImagePicker();
|
||||
final x = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (x != null) {
|
||||
Navigator.of(context).pop<String>(x.path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAsset(AssetEntity e) async {
|
||||
final f = await e.file;
|
||||
if (!mounted) return;
|
||||
if (f != null) {
|
||||
Navigator.of(context).pop<String>(f.path);
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not open this photo. Try another.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const int _crossCount = 3;
|
||||
static const double _spacing = 3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomPad = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop<String?>(),
|
||||
icon: const Icon(LucideIcons.x, color: AppColors.textPrimary),
|
||||
),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Choose photo',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
Expanded(
|
||||
child: _busy
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
)
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await PhotoManager.openSetting();
|
||||
},
|
||||
child: const Text('Open Settings'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _init,
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
controller: _scrollController,
|
||||
cacheExtent: 220,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
_spacing,
|
||||
_spacing,
|
||||
_spacing,
|
||||
_spacing + bottomPad,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _crossCount,
|
||||
mainAxisSpacing: _spacing,
|
||||
crossAxisSpacing: _spacing,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount:
|
||||
1 + _assets.length + (_loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return _CameraGridTile(onTap: _onCamera);
|
||||
}
|
||||
final ai = index - 1;
|
||||
if (ai >= _assets.length) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final asset = _assets[ai];
|
||||
return GestureDetector(
|
||||
onTap: () => _onAsset(asset),
|
||||
child: _AlbumThumbnail(asset: asset),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CameraGridTile extends StatelessWidget {
|
||||
const _CameraGridTile({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.surfaceAlt,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.camera,
|
||||
size: 32,
|
||||
color: AppColors.primary.withValues(alpha: 0.9),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
'Camera',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.95),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumThumbnail extends StatefulWidget {
|
||||
const _AlbumThumbnail({required this.asset});
|
||||
|
||||
final AssetEntity asset;
|
||||
|
||||
@override
|
||||
State<_AlbumThumbnail> createState() => _AlbumThumbnailState();
|
||||
}
|
||||
|
||||
class _AlbumThumbnailState extends State<_AlbumThumbnail> {
|
||||
Uint8List? _bytes;
|
||||
bool _loadFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AlbumThumbnail oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.asset.id != widget.asset.id) {
|
||||
_bytes = null;
|
||||
_loadFailed = false;
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.asset.thumbnailDataWithSize(
|
||||
const ThumbnailSize.square(200),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bytes = data;
|
||||
_loadFailed = data == null || data.isEmpty;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bytes = null;
|
||||
_loadFailed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final b = _bytes;
|
||||
return ColoredBox(
|
||||
color: AppColors.surfaceAlt,
|
||||
child: b != null && b.isNotEmpty
|
||||
? Image.memory(
|
||||
b,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
)
|
||||
: _loadFailed
|
||||
? const Icon(
|
||||
LucideIcons.image_off,
|
||||
size: 28,
|
||||
color: AppColors.textSecondary,
|
||||
)
|
||||
: const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,10 @@ class VideoCard extends StatefulWidget {
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.videoUrl,
|
||||
this.cover,
|
||||
this.credits = '50',
|
||||
this.showCreditsBadge = true,
|
||||
this.showBottomGenerateButton = true,
|
||||
required this.isActive,
|
||||
required this.onPlayRequested,
|
||||
required this.onStopRequested,
|
||||
@ -26,7 +29,11 @@ class VideoCard extends StatefulWidget {
|
||||
|
||||
final String imageUrl;
|
||||
final String? videoUrl;
|
||||
/// 非空时用自定义封面(如相册视频缩略图),替代 [CachedNetworkImage](imageUrl)
|
||||
final Widget? cover;
|
||||
final String credits;
|
||||
final bool showCreditsBadge;
|
||||
final bool showBottomGenerateButton;
|
||||
final bool isActive;
|
||||
final VoidCallback onPlayRequested;
|
||||
final VoidCallback onStopRequested;
|
||||
@ -203,7 +210,13 @@ class _VideoCardState extends State<VideoCard> {
|
||||
if (videoPath == null || videoPath.isEmpty) {
|
||||
throw StateError('Video file stream ended without FileInfo');
|
||||
}
|
||||
setState(_clearBottomProgress);
|
||||
// 下载结束后到解码完成前:底部 2px 不确定进度(缓存直出时下载阶段可能无 DownloadProgress)
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showBottomProgress = true;
|
||||
_bottomProgressFraction = null;
|
||||
});
|
||||
}
|
||||
|
||||
_controller = VideoPlayerController.file(
|
||||
File(videoPath),
|
||||
@ -211,9 +224,11 @@ class _VideoCardState extends State<VideoCard> {
|
||||
);
|
||||
await _controller!.initialize();
|
||||
if (!mounted || gen != _loadGen || !widget.isActive) {
|
||||
if (mounted) setState(_clearBottomProgress);
|
||||
_disposeController();
|
||||
return;
|
||||
}
|
||||
if (mounted) setState(_clearBottomProgress);
|
||||
_controller!.setVolume(0);
|
||||
_controller!.setLooping(true);
|
||||
_controller!.addListener(_onVideoUpdate);
|
||||
@ -274,16 +289,17 @@ class _VideoCardState extends State<VideoCard> {
|
||||
child: GestureDetector(
|
||||
onTap: widget.onGenerateSimilar,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
),
|
||||
child: widget.cover ??
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showVideoLayer)
|
||||
@ -313,83 +329,85 @@ class _VideoCardState extends State<VideoCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.overlayDark,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 12,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.credits,
|
||||
style: const TextStyle(
|
||||
if (widget.showCreditsBadge)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.overlayDark,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 12,
|
||||
color: AppColors.surface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Center(
|
||||
child: IntrinsicWidth(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onGenerateSimilar,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryButtonFill,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primary,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryButtonShadow,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Generate Similar',
|
||||
style: TextStyle(
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.credits,
|
||||
style: const TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showBottomGenerateButton)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Center(
|
||||
child: IntrinsicWidth(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onGenerateSimilar,
|
||||
child: Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryButtonFill,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primary,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryButtonShadow,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Generate Similar',
|
||||
style: TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showBottomProgress)
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
||||
@ -36,6 +36,7 @@ dependencies:
|
||||
flutter_native_splash: ^2.4.7
|
||||
android_id: ^0.5.1
|
||||
visibility_detector: ^0.4.0+2
|
||||
photo_manager: ^3.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user