From 2ae2c19024258b1b6f38d66f5ec9484b3fa3f79a Mon Sep 17 00:00:00 2001 From: ivan Date: Sun, 29 Mar 2026 23:53:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=8B=8D=E7=85=A7?= =?UTF-8?q?=E6=AD=BB=E6=9C=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 10 +- ios/Runner/GeneratedPluginRegistrant.m | 14 + lib/core/auth/auth_service.dart | 21 ++ lib/core/util/image_compress.dart | 37 ++ lib/features/gallery/gallery_screen.dart | 112 ++++-- .../gallery/video_thumbnail_cache.dart | 31 ++ .../generate_video/generate_video_screen.dart | 349 +++++++++++++----- .../generate_video/in_app_camera_page.dart | 219 +++++++++++ .../widgets/album_picker_sheet.dart | 176 ++++++++- lib/features/home/home_screen.dart | 31 +- lib/features/home/widgets/video_card.dart | 11 +- pubspec.yaml | 5 +- 12 files changed, 865 insertions(+), 151 deletions(-) create mode 100644 lib/core/util/image_compress.dart create mode 100644 lib/features/generate_video/in_app_camera_page.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f8033e3..bdeef88 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,14 @@ - + + - + + ) +#import +#else +@import camera_avfoundation; +#endif + #if __has_include() #import #else @@ -48,6 +54,12 @@ @import in_app_purchase_storekit; #endif +#if __has_include() +#import +#else +@import permission_handler_apple; +#endif + #if __has_include() #import #else @@ -100,12 +112,14 @@ + (void)registerWithRegistry:(NSObject*)registry { [AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]]; + [CameraPlugin registerWithRegistrar:[registry registrarForPlugin:@"CameraPlugin"]]; [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [FacebookAppEventsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FacebookAppEventsPlugin"]]; [FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]]; [GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]]; + [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]]; [ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 91cabd5..e099971 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -127,6 +127,27 @@ class AuthService { } } + /// 系统相册/相机 Activity 返回后,若开启了截屏防护([UserState.safeArea]),部分机型会黑屏卡死甚至重启。 + /// 在整段选图流程外包一层:临时关闭防护,结束后按当前配置恢复。 + static Future runWithNativeMediaPicker(Future Function() action) async { + if (defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS) { + return await action(); + } + try { + await ScreenSecure.disableScreenshotBlock(); + await ScreenSecure.disableScreenRecordBlock(); + _logMsg('native media picker: ScreenSecure released'); + } on ScreenSecureException catch (e) { + _logMsg('native media picker: disable failed: ${e.message}'); + } + try { + return await action(); + } finally { + await _applyScreenSecure(UserState.safeArea.value); + } + } + /// 将 common_info 响应保存到全局,并解析 surge 中的 lucky(是否开启三方支付) static void _saveCommonInfoToState(Map data) { final reveal = data['reveal'] as int?; diff --git a/lib/core/util/image_compress.dart b/lib/core/util/image_compress.dart new file mode 100644 index 0000000..0cb5db3 --- /dev/null +++ b/lib/core/util/image_compress.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:image/image.dart' as img; +import 'package:path_provider/path_provider.dart'; + +/// 上传前压缩:限制长边、JPEG 质量,减轻内存与带宽;解码失败时返回原文件。 +Future compressImageForUpload( + File source, { + int maxSide = 2048, + int jpegQuality = 85, +}) async { + try { + final raw = await source.readAsBytes(); + final image = img.decodeImage(raw); + if (image == null) return source; + + var work = image; + if (work.width > maxSide || work.height > maxSide) { + if (work.width >= work.height) { + work = img.copyResize(work, width: maxSide, interpolation: img.Interpolation.linear); + } else { + work = img.copyResize(work, height: maxSide, interpolation: img.Interpolation.linear); + } + } + + final jpg = img.encodeJpg(work, quality: jpegQuality); + + final dir = await getTemporaryDirectory(); + final out = File( + '${dir.path}/upload_${DateTime.now().millisecondsSinceEpoch}.jpg', + ); + await out.writeAsBytes(jpg, flush: true); + return out; + } catch (_) { + return source; + } +} diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index 845b604..a22bd92 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -35,12 +36,14 @@ class _GalleryScreenState extends State { final ScrollController _scrollController = ScrollController(); static const int _pageSize = 20; - /// 与首页一致:足够可见比例的格子才自动静音循环播视频(允许多个) - static const double _videoVisibilityThreshold = 0.15; + /// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」 + static const double _videoVisibilityThreshold = 0.08; + /// 一屏约 2×(3~4) 个格子;不再强行只播 2 路(会总挑中间行)。滑出后 [VideoCard] 会释放解码器,此处仅防极端滚动时实例过多 + static const int _maxConcurrentGalleryVideos = 16; Set _visibleVideoIndices = {}; final Set _userPausedVideoIndices = {}; final Map _cardVisibleFraction = {}; - bool _visibilityReconcileScheduled = false; + Timer? _visibilityDebounce; /// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow], /// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。 @@ -85,6 +88,7 @@ class _GalleryScreenState extends State { @override void dispose() { + _visibilityDebounce?.cancel(); _scrollController.dispose(); super.dispose(); } @@ -105,26 +109,30 @@ class _GalleryScreenState extends State { } else { _cardVisibleFraction.remove(index); } - if (_visibilityReconcileScheduled) return; - _visibilityReconcileScheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _visibilityReconcileScheduled = false; + // 防抖:滚动时可见性回调极频繁,立刻 setState 会让 VideoCard / FutureBuilder 反复重建导致闪屏 + _visibilityDebounce?.cancel(); + _visibilityDebounce = Timer(const Duration(milliseconds: 120), () { if (mounted) _reconcileVisibleVideoIndicesFromDetector(); }); } void _reconcileVisibleVideoIndicesFromDetector() { final items = _gridItems; - final next = {}; + final scored = >[]; 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); + scored.add(MapEntry(i, e.value)); } } + scored.sort((a, b) => b.value.compareTo(a.value)); + final next = scored + .take(_maxConcurrentGalleryVideos) + .map((e) => e.key) + .toSet(); _userPausedVideoIndices.removeWhere((i) => !next.contains(i)); if (!_setsEqual(next, _visibleVideoIndices)) { setState(() => _visibleVideoIndices = next); @@ -273,6 +281,7 @@ class _GalleryScreenState extends State { physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, + cacheExtent: 800, padding: EdgeInsets.fromLTRB( AppSpacing.screenPadding, AppSpacing.xl, @@ -324,33 +333,35 @@ class _GalleryScreenState extends State { _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, + child: RepaintBoundary( + 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, + ), ), ); }, @@ -364,20 +375,45 @@ class _GalleryScreenState extends State { } } -class _VideoThumbnailCover extends StatelessWidget { +class _VideoThumbnailCover extends StatefulWidget { const _VideoThumbnailCover({required this.videoUrl}); final String videoUrl; + @override + State<_VideoThumbnailCover> createState() => _VideoThumbnailCoverState(); +} + +class _VideoThumbnailCoverState extends State<_VideoThumbnailCover> { + /// 必须在 State 内固定同一 Future;否则父级每次 build 新建 Future,[FutureBuilder] 会重置并闪一下占位图 + late Future _thumbFuture; + + @override + void initState() { + super.initState(); + _thumbFuture = + VideoThumbnailCache.instance.getThumbnail(widget.videoUrl); + } + + @override + void didUpdateWidget(covariant _VideoThumbnailCover oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.videoUrl != widget.videoUrl) { + _thumbFuture = + VideoThumbnailCache.instance.getThumbnail(widget.videoUrl); + } + } + @override Widget build(BuildContext context) { return FutureBuilder( - future: VideoThumbnailCache.instance.getThumbnail(videoUrl), + future: _thumbFuture, builder: (context, snapshot) { if (snapshot.hasData && snapshot.data != null) { return Image.memory( snapshot.data!, fit: BoxFit.cover, + gaplessPlayback: true, ); } return Container( diff --git a/lib/features/gallery/video_thumbnail_cache.dart b/lib/features/gallery/video_thumbnail_cache.dart index 07c4dee..3cb299d 100644 --- a/lib/features/gallery/video_thumbnail_cache.dart +++ b/lib/features/gallery/video_thumbnail_cache.dart @@ -15,6 +15,37 @@ class VideoThumbnailCache { static const int _maxWidth = 400; static const int _quality = 75; + /// 全屏背景等大图:无 ExoPlayer,仅抽一帧 JPEG(例如生成页背景,长边约 [maxWidth]) + Future getPosterFrame(String videoUrl, {int maxWidth = 1024}) async { + final key = '${_cacheKey(videoUrl)}_poster_$maxWidth'; + 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: 78, + ); + 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; + } + Future getThumbnail(String videoUrl) async { final key = _cacheKey(videoUrl); final cacheDir = await _getCacheDir(); diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 694fd53..1ec6f84 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -8,17 +9,20 @@ import 'package:http/http.dart' as http; import 'package:video_player/video_player.dart'; import '../../core/auth/auth_service.dart'; +import '../../core/util/image_compress.dart'; import '../../core/log/app_logger.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/theme/app_typography.dart'; import '../../core/user/account_refresh.dart'; import '../../core/user/user_state.dart'; +import '../../features/gallery/video_thumbnail_cache.dart'; import '../../features/home/home_playback_resume.dart'; import '../../features/home/models/task_item.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../../core/api/services/image_api.dart'; +import 'in_app_camera_page.dart'; import 'widgets/album_picker_sheet.dart'; /// Generate Video screen - matches Pencil mmLB5 @@ -38,6 +42,10 @@ enum _Resolution { p480, p720 } class _GenerateVideoScreenState extends State { _Resolution _selectedResolution = _Resolution.p480; bool _isGenerating = false; + /// 防止在选图/相机流程未结束时再次进入,避免并发 native picker / ScreenSecure 错乱 + bool _isPickingMedia = false; + /// 应用内相机打开时暂停背景 [VideoPlayer],避免与 CameraPreview 争用 Surface + bool _suspendBackgroundVideo = false; int get _currentCredits { final task = widget.task; @@ -77,7 +85,7 @@ class _GenerateVideoScreenState extends State { /// Click flow per docs/generate_video.md: tap Generate Video -> image picker /// (camera or gallery) -> after image selected -> proceed to API. Future _onGenerateButtonTap() async { - if (_isGenerating) return; + if (_isGenerating || _isPickingMedia) return; final userCredits = UserState.credits.value ?? 0; if (userCredits < _currentCredits) { @@ -87,19 +95,62 @@ class _GenerateVideoScreenState extends State { return; } - final path = await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (ctx) => SizedBox( - height: MediaQuery.sizeOf(ctx).height * 0.92, - child: const AlbumPickerSheet(), - ), - ); - if (path == null || path.isEmpty || !mounted) return; + _isPickingMedia = true; + if (mounted) setState(() {}); - final file = File(path); - await _runGenerationApi(file); + try { + await AuthService.runWithNativeMediaPicker(() async { + final path = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => SizedBox( + height: MediaQuery.sizeOf(ctx).height * 0.92, + child: const AlbumPickerSheet(), + ), + ); + if (!mounted) return; + + if (path == kAlbumPickerRequestCamera) { + // 应用内相机:推全屏页前暂停背景视频,返回后短暂延迟再恢复,降低 GPU 切换毛刺 + setState(() => _suspendBackgroundVideo = true); + await Future.delayed(const Duration(milliseconds: 80)); + if (!mounted) return; + final capturedPath = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => const InAppCameraPage(), + ), + ); + if (mounted) { + await Future.delayed(const Duration(milliseconds: 150)); + if (mounted) { + setState(() => _suspendBackgroundVideo = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + } + if (!mounted) return; + if (capturedPath != null && capturedPath.isNotEmpty) { + final f = File(capturedPath); + if (await f.exists()) { + await _runGenerationApi(f); + } + } + return; + } + + if (path == null || path.isEmpty) return; + + final file = File(path); + await _runGenerationApi(file); + }); + } finally { + _isPickingMedia = false; + if (mounted) setState(() {}); + } } Future _runGenerationApi(File file) async { @@ -108,8 +159,14 @@ class _GenerateVideoScreenState extends State { try { await AuthService.loginComplete; - final size = await file.length(); - final ext = file.path.split('.').last.toLowerCase(); + final toUpload = await compressImageForUpload( + file, + maxSide: 1024, + jpegQuality: 75, + ); + + final size = await toUpload.length(); + final ext = toUpload.path.split('.').last.toLowerCase(); final contentType = ext == 'png' ? 'image/png' : ext == 'gif' @@ -152,7 +209,7 @@ class _GenerateVideoScreenState extends State { headers['Content-Type'] = contentType; } - final bytes = await file.readAsBytes(); + final bytes = await toUpload.readAsBytes(); final uploadResponse = await http.put( Uri.parse(uploadUrl), headers: headers, @@ -255,9 +312,10 @@ class _GenerateVideoScreenState extends State { body: Stack( fit: StackFit.expand, children: [ - _GenerateFullScreenBackground( + _GenerateBackgroundLayer( videoUrl: widget.task?.previewVideoUrl, imageUrl: widget.task?.previewImageUrl, + suspendVideoPlayback: _suspendBackgroundVideo, ), Positioned.fill( child: DecoratedBox( @@ -297,7 +355,7 @@ class _GenerateVideoScreenState extends State { if (_hasVideo) const SizedBox(height: AppSpacing.xxl), _GenerateButton( onGenerate: _onGenerateButtonTap, - isLoading: _isGenerating, + isLoading: _isGenerating || _isPickingMedia, credits: _currentCredits.toString(), ), ], @@ -310,131 +368,246 @@ class _GenerateVideoScreenState extends State { } } -/// 列表传入的预览图/视频:全屏背景层,超出视口 [BoxFit.cover] 裁切。 -class _GenerateFullScreenBackground extends StatefulWidget { - const _GenerateFullScreenBackground({ +/// 生成页背景:有预览视频则循环播放;打开应用内相机时暂停并显示静帧,避免与 CameraPreview 冲突。 +class _GenerateBackgroundLayer extends StatefulWidget { + const _GenerateBackgroundLayer({ this.videoUrl, this.imageUrl, + this.suspendVideoPlayback = false, }); final String? videoUrl; final String? imageUrl; + final bool suspendVideoPlayback; @override - State<_GenerateFullScreenBackground> createState() => - _GenerateFullScreenBackgroundState(); + State<_GenerateBackgroundLayer> createState() => + _GenerateBackgroundLayerState(); } -class _GenerateFullScreenBackgroundState - extends State<_GenerateFullScreenBackground> { +class _GenerateBackgroundLayerState extends State<_GenerateBackgroundLayer> { VideoPlayerController? _controller; + int _videoLoadGen = 0; + + Uint8List? _videoPosterBytes; + bool _videoPosterLoading = false; + + void _bumpVideoLoadGen() { + _videoLoadGen++; + } @override void initState() { super.initState(); - if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { + _loadVideoPosterIfNeeded(); + if (!widget.suspendVideoPlayback && + widget.videoUrl != null && + widget.videoUrl!.isNotEmpty) { _loadAndPlay(); } } @override void dispose() { + _bumpVideoLoadGen(); _controller?.dispose(); _controller = null; super.dispose(); } @override - void didUpdateWidget(covariant _GenerateFullScreenBackground oldWidget) { + void didUpdateWidget(covariant _GenerateBackgroundLayer oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.videoUrl != widget.videoUrl) { + if (oldWidget.videoUrl != widget.videoUrl || + oldWidget.imageUrl != widget.imageUrl) { + _videoPosterBytes = null; + _videoPosterLoading = false; + _loadVideoPosterIfNeeded(); + _bumpVideoLoadGen(); _controller?.dispose(); _controller = null; - if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { + if (widget.videoUrl != null && + widget.videoUrl!.isNotEmpty && + !widget.suspendVideoPlayback) { + _loadAndPlay(); + } else if (mounted) { + setState(() {}); + } + return; + } + if (widget.suspendVideoPlayback != oldWidget.suspendVideoPlayback) { + if (widget.suspendVideoPlayback) { + _bumpVideoLoadGen(); + _controller?.pause(); + _controller?.dispose(); + _controller = null; + if (mounted) setState(() {}); + } else if (widget.videoUrl != null && + widget.videoUrl!.isNotEmpty) { _loadAndPlay(); } } } + Future _loadVideoPosterIfNeeded() async { + final v = widget.videoUrl; + final img = widget.imageUrl; + if (v == null || v.isEmpty) return; + if (img != null && img.isNotEmpty) return; + + if (mounted) setState(() => _videoPosterLoading = true); + final bytes = await VideoThumbnailCache.instance.getPosterFrame(v); + if (!mounted) return; + setState(() { + _videoPosterBytes = bytes; + _videoPosterLoading = false; + }); + } + Future _loadAndPlay() async { final url = widget.videoUrl; if (url == null || url.isEmpty) return; + if (widget.suspendVideoPlayback) return; - setState(() {}); + final myGen = ++_videoLoadGen; + if (mounted) setState(() {}); + VideoPlayerController? created; try { final file = await DefaultCacheManager().getSingleFile(url); - if (!mounted) return; - final controller = VideoPlayerController.file( + if (!mounted || myGen != _videoLoadGen) return; + created = VideoPlayerController.file( file, videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), ); - await controller.initialize(); - if (!mounted) return; - await controller.setVolume(1.0); - await controller.setLooping(true); - await controller.play(); - if (mounted) { - setState(() { - _controller = controller; - }); + await created.initialize(); + if (!mounted || myGen != _videoLoadGen) return; + await created.setVolume(1.0); + await created.setLooping(true); + await created.play(); + if (!mounted || myGen != _videoLoadGen) return; + if (widget.suspendVideoPlayback) return; + final toAttach = created; + created = null; + if (!mounted || myGen != _videoLoadGen) { + await toAttach.dispose(); + return; } + final previous = _controller; + setState(() => _controller = toAttach); + previous?.dispose(); } catch (e) { GenerateVideoScreen._log.e('Video load failed', e); if (mounted) setState(() {}); + } finally { + final orphan = created; + if (orphan != null) { + await orphan.dispose(); + } } } @override Widget build(BuildContext context) { - final isReady = _controller != null && _controller!.value.isInitialized; - final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; - final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty; + final suspended = widget.suspendVideoPlayback; + final videoUrl = widget.videoUrl; + final hasVideoUrl = videoUrl != null && videoUrl.isNotEmpty; + final hasImageUrl = widget.imageUrl != null && widget.imageUrl!.isNotEmpty; + final videoReady = !suspended && + _controller != null && + _controller!.value.isInitialized; - return Stack( - fit: StackFit.expand, - children: [ - if (!hasVideo && hasImage) - Positioned.fill( - child: CachedNetworkImage( - imageUrl: widget.imageUrl!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - placeholder: (_, __) => const _BgLoadingPlaceholder(), - errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), - ), - ) - else if (hasVideo && isReady) - Positioned.fill( - child: ClipRect( - child: FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: _controller!.value.size.width, - height: _controller!.value.size.height, - child: VideoPlayer(_controller!), - ), - ), - ), - ) - else if (hasVideo && hasImage) - Positioned.fill( - child: CachedNetworkImage( - imageUrl: widget.imageUrl!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - placeholder: (_, __) => const _BgLoadingPlaceholder(), - errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), - ), - ) - else if (hasVideo) - const Positioned.fill(child: _BgLoadingPlaceholder()) - else - const Positioned.fill(child: _BgErrorPlaceholder()), - ], - ); + if (hasImageUrl && !hasVideoUrl) { + return CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => const _BgLoadingPlaceholder(), + errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), + ); + } + + if (suspended && hasVideoUrl) { + if (hasImageUrl) { + return CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => const _BgLoadingPlaceholder(), + errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), + ); + } + final bytes = _videoPosterBytes; + if (bytes != null && bytes.isNotEmpty) { + return Image.memory( + bytes, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + gaplessPlayback: true, + ); + } + if (_videoPosterLoading) { + return const _BgLoadingPlaceholder(); + } + return const ColoredBox(color: Colors.black); + } + + if (hasVideoUrl && videoReady) { + return ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _controller!.value.size.width, + height: _controller!.value.size.height, + child: VideoPlayer(_controller!), + ), + ), + ); + } + + if (hasVideoUrl && hasImageUrl) { + return CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => const _BgLoadingPlaceholder(), + errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), + ); + } + + if (hasVideoUrl) { + final bytes = _videoPosterBytes; + if (bytes != null && bytes.isNotEmpty) { + return Image.memory( + bytes, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + gaplessPlayback: true, + ); + } + if (_videoPosterLoading) { + return const _BgLoadingPlaceholder(); + } + return const _BgLoadingPlaceholder(); + } + + if (hasImageUrl) { + return CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, __) => const _BgLoadingPlaceholder(), + errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), + ); + } + + return const _BgErrorPlaceholder(); } } diff --git a/lib/features/generate_video/in_app_camera_page.dart b/lib/features/generate_video/in_app_camera_page.dart new file mode 100644 index 0000000..88a24f3 --- /dev/null +++ b/lib/features/generate_video/in_app_camera_page.dart @@ -0,0 +1,219 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_lucide/flutter_lucide.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../core/log/app_logger.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_spacing.dart'; + +/// 应用内拍照:不跳出系统相机 Activity,避免与 Flutter Surface / 截屏防护等叠加导致黑屏卡死。 +class InAppCameraPage extends StatefulWidget { + const InAppCameraPage({super.key}); + + static final _log = AppLogger('InAppCamera'); + + @override + State createState() => _InAppCameraPageState(); +} + +class _InAppCameraPageState extends State { + CameraController? _controller; + bool _initializing = true; + bool _capturing = false; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _prepareCamera()); + } + + Future _prepareCamera() async { + final status = await Permission.camera.request(); + if (!status.isGranted) { + if (mounted) { + setState(() { + _initializing = false; + _error = 'Camera permission is required'; + }); + } + return; + } + + try { + final cameras = await availableCameras(); + if (cameras.isEmpty) { + if (mounted) { + setState(() { + _initializing = false; + _error = 'No camera available'; + }); + } + return; + } + final back = cameras.where((c) => c.lensDirection == CameraLensDirection.back); + final selected = back.isEmpty ? cameras.first : back.first; + + final c = CameraController( + selected, + ResolutionPreset.medium, + enableAudio: false, + ); + await c.initialize(); + if (!mounted) { + await c.dispose(); + return; + } + setState(() { + _controller = c; + _initializing = false; + }); + } catch (e, st) { + InAppCameraPage._log.e('Camera init failed', e, st); + if (mounted) { + setState(() { + _initializing = false; + _error = 'Could not open camera'; + }); + } + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + Future _onShutter() async { + final c = _controller; + if (c == null || !c.value.isInitialized || _capturing) return; + setState(() => _capturing = true); + try { + final shot = await c.takePicture(); + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(shot.path); + } catch (e, st) { + InAppCameraPage._log.e('takePicture failed', e, st); + if (mounted) setState(() => _capturing = false); + } + } + + void _onClose() { + Navigator.of(context, rootNavigator: true).pop(null); + } + + @override + Widget build(BuildContext context) { + final c = _controller; + final showPreview = + !_initializing && _error == null && c != null && c.value.isInitialized; + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + if (showPreview) + Positioned.fill( + child: ColoredBox( + color: Colors.black, + // 勿再包一层自定义 aspect 的 SizedBox/FittedBox:[CameraPreview] 已在竖屏下使用 + // 1/value.aspectRatio + Android [RotatedBox],外层乱算比例会把它压扁。 + child: Center( + child: CameraPreview(c), + ), + ), + ), + SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + if (_initializing) + const Center( + child: + CircularProgressIndicator(color: AppColors.primary), + ) + else if (_error != null) + Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.camera_off, + size: 48, + color: AppColors.surface.withValues(alpha: 0.6), + ), + const SizedBox(height: AppSpacing.md), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.surface.withValues(alpha: 0.85), + fontSize: 15, + ), + ), + const SizedBox(height: AppSpacing.lg), + TextButton( + onPressed: _onClose, + child: const Text('Close'), + ), + ], + ), + ), + ), + if (showPreview) + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: _capturing ? null : _onClose, + icon: Icon( + LucideIcons.x, + color: AppColors.surface.withValues(alpha: 0.9), + size: 28, + ), + ), + GestureDetector( + onTap: _capturing ? null : _onShutter, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppColors.surface, + width: 4, + ), + color: AppColors.surface.withValues(alpha: 0.2), + ), + child: _capturing + ? const Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.surface, + ), + ) + : null, + ), + ), + const SizedBox(width: 48), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/generate_video/widgets/album_picker_sheet.dart b/lib/features/generate_video/widgets/album_picker_sheet.dart index fb9d114..d03d84c 100644 --- a/lib/features/generate_video/widgets/album_picker_sheet.dart +++ b/lib/features/generate_video/widgets/album_picker_sheet.dart @@ -1,13 +1,17 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; 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'; +/// [showModalBottomSheet] 返回此值时由**外层页面**调系统相机,避免 BottomSheet 与相机 Activity 叠放导致返回后黑屏/卡死。 +const String kAlbumPickerRequestCamera = '__album_picker_camera__'; + /// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致) class AlbumPickerSheet extends StatefulWidget { const AlbumPickerSheet({super.key}); @@ -29,6 +33,8 @@ class _AlbumPickerSheetState extends State { int _loadedPage = -1; int _totalCount = 0; bool _loadingMore = false; + /// 最近一次权限结果(用于「仅部分照片」等说明) + PermissionState _permissionState = PermissionState.notDetermined; @override void initState() { @@ -55,6 +61,7 @@ class _AlbumPickerSheetState extends State { final state = await PhotoManager.requestPermissionExtend(); if (!mounted) return; + _permissionState = state; if (!state.hasAccess) { setState(() { @@ -78,12 +85,25 @@ class _AlbumPickerSheetState extends State { _busy = false; _recentAlbum = null; _totalCount = 0; + _assets.clear(); + _loadedPage = -1; }); return; } _recentAlbum = paths.first; _totalCount = await _recentAlbum!.assetCountAsync; + if (!mounted) return; + + if (_totalCount == 0) { + setState(() { + _assets.clear(); + _loadedPage = -1; + _busy = false; + }); + return; + } + final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize); if (!mounted) return; @@ -96,6 +116,22 @@ class _AlbumPickerSheetState extends State { }); } + /// iOS:系统「补充照片」面板;Android 等:打开系统设置改权限 + Future _openManagePhotoAccess() async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + try { + await PhotoManager.presentLimited(); + } catch (_) { + await PhotoManager.openSetting(); + return; + } + } else { + await PhotoManager.openSetting(); + return; + } + if (mounted) await _init(); + } + void _maybeLoadMore() { if (_loadingMore || _busy || _recentAlbum == null) return; if (_assets.length >= _totalCount) return; @@ -131,15 +167,9 @@ class _AlbumPickerSheetState extends State { } Future _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(x.path); - } + // 不在弹层内调 pickImage:BottomSheet 与相机 Activity 叠加时,返回后 Surface 有概率无法恢复(全黑)。 + Navigator.of(context).pop(kAlbumPickerRequestCamera); } Future _onAsset(AssetEntity e) async { @@ -148,15 +178,25 @@ class _AlbumPickerSheetState extends State { if (f != null) { Navigator.of(context).pop(f.path); } else if (mounted) { + final limited = _permissionState == PermissionState.limited; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Could not open this photo. Try another.'), + SnackBar( + content: Text( + limited + ? 'Cannot read this photo. It may not be included in your ' + 'current selection. Use "Manage photo access" or choose ' + 'another image.' + : 'Could not open this photo. Try another.', + ), behavior: SnackBarBehavior.floating, ), ); } } + bool get _showNoPhotosHelp => + !_busy && _error == null && _totalCount == 0; + static const int _crossCount = 3; static const double _spacing = 3; @@ -234,7 +274,18 @@ class _AlbumPickerSheetState extends State { ), ), ) - : GridView.builder( + : _showNoPhotosHelp + ? _NoPhotosInLibraryPanel( + isLimitedAccess: + _permissionState == PermissionState.limited, + onManageAccess: _openManagePhotoAccess, + onOpenSettings: () async { + await PhotoManager.openSetting(); + }, + onRetry: _init, + onUseCamera: _onCamera, + ) + : GridView.builder( controller: _scrollController, cacheExtent: 220, padding: EdgeInsets.fromLTRB( @@ -283,6 +334,107 @@ class _AlbumPickerSheetState extends State { } } +/// 权限为「部分照片」或相册对本应用不可见时:引导补充选图或去设置,避免只显示相机无说明 +class _NoPhotosInLibraryPanel extends StatelessWidget { + const _NoPhotosInLibraryPanel({ + required this.isLimitedAccess, + required this.onManageAccess, + required this.onOpenSettings, + required this.onRetry, + required this.onUseCamera, + }); + + final bool isLimitedAccess; + final Future Function() onManageAccess; + final Future Function() onOpenSettings; + final Future Function() onRetry; + final Future Function() onUseCamera; + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.paddingOf(context).bottom; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.xl + bottom, + ), + child: Column( + children: [ + Icon( + LucideIcons.folder_open, + size: 48, + color: AppColors.textSecondary.withValues(alpha: 0.9), + ), + const SizedBox(height: AppSpacing.lg), + Text( + isLimitedAccess + ? 'No photos shared with this app' + : 'No photos available', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + isLimitedAccess + ? 'You allowed access to only some photos, or none are ' + 'selected for this app. Add photos or change permission, ' + 'then try again.' + : 'We could not load any images. Check photo access in ' + 'Settings or try again.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + height: 1.4, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.xl), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.surface, + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => onManageAccess(), + child: Text( + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS + ? 'Select photos' + : 'Manage photo access', + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + onPressed: () => onOpenSettings(), + child: const Text('Open Settings'), + ), + TextButton( + onPressed: () => onRetry(), + child: const Text('Try again'), + ), + const SizedBox(height: AppSpacing.md), + TextButton.icon( + onPressed: () => onUseCamera(), + icon: const Icon(LucideIcons.camera, size: 20), + label: const Text('Take photo with camera'), + ), + ], + ), + ); + } +} + class _CameraGridTile extends StatelessWidget { const _CameraGridTile({required this.onTap}); diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 4abcdbd..e51472e 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -101,7 +101,10 @@ class _HomeScreenState extends State { _loadCategories(); } - static const double _videoVisibilityThreshold = 0.15; + /// 与 Gallery 一致:边缘格子可见比例偏低时也应参与自动播 + static const double _videoVisibilityThreshold = 0.08; + /// 凡达到阈值的格子均可播;滑出后 [VideoCard] 会释放解码器,此处仅防极端情况 + static const int _maxConcurrentHomeVideos = 16; void _onGridCardVisibilityChanged(int index, VisibilityInfo info) { if (!mounted) return; @@ -120,16 +123,21 @@ class _HomeScreenState extends State { void _reconcileVisibleVideoIndicesFromDetector() { final tasks = _displayTasks; - final next = {}; + final scored = >[]; for (final e in _cardVisibleFraction.entries) { final i = e.key; if (i < 0 || i >= tasks.length) continue; if (e.value < _videoVisibilityThreshold) continue; final url = tasks[i].previewVideoUrl; if (url != null && url.isNotEmpty) { - next.add(i); + scored.add(MapEntry(i, e.value)); } } + scored.sort((a, b) => b.value.compareTo(a.value)); + final next = scored + .take(_maxConcurrentHomeVideos) + .map((e) => e.key) + .toSet(); _userPausedVideoIndices.removeWhere((i) => !next.contains(i)); if (!_setsEqual(next, _visibleVideoIndices)) { setState(() => _visibleVideoIndices = next); @@ -273,10 +281,17 @@ class _HomeScreenState extends State { }); } setState(() => _tasksLoading = false); + // 列表替换后须强制可见性重算,否则探测器有时不回调,预览一直不播 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.isActive) _scheduleVisibilityRefresh(); + }); } } void _onTabChanged(CategoryItem c) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } setState(() => _selectedCategory = c); if (c.id == kExtCategoryId) { setState(() { @@ -286,6 +301,9 @@ class _HomeScreenState extends State { _visibleVideoIndices = {}; _cardVisibleFraction.clear(); }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.isActive) _scheduleVisibilityRefresh(); + }); } else { _loadTasks(c.id); } @@ -353,6 +371,7 @@ class _HomeScreenState extends State { AppSpacing.screenPaddingLarge, ), controller: _scrollController, + cacheExtent: 800, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, @@ -378,10 +397,10 @@ class _HomeScreenState extends State { _placeholderImage, videoUrl: task.previewVideoUrl, credits: credits, - isActive: + isActive: widget.isActive && _visibleVideoIndices.contains(index) && - !_userPausedVideoIndices - .contains(index), + !_userPausedVideoIndices + .contains(index), onPlayRequested: () => setState(() => _userPausedVideoIndices.remove(index)), onStopRequested: () => setState(() => diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index 6437452..9799f2c 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -72,15 +72,18 @@ class _VideoCardState extends State { void didUpdateWidget(covariant VideoCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isActive && !widget.isActive) { - _stop(); + _releaseVideoDecoder(); } else if (!oldWidget.isActive && widget.isActive) { _loadAndPlay(); } } - void _stop() { + /// 仅 pause 会占用 MediaCodec;滑出网格后必须 [dispose] ExoPlayer,否则多划几次后 OMX 解码器耗尽报错。 + void _releaseVideoDecoder() { + _loadGen++; _controller?.removeListener(_onVideoUpdate); - _controller?.pause(); + _disposeController(); + _clearBottomProgress(); if (mounted) { setState(() { _videoOpacityTarget = false; @@ -163,7 +166,7 @@ class _VideoCardState extends State { await _controller!.play(); if (!mounted || gen != _loadGen) return; if (!widget.isActive) { - _stop(); + _releaseVideoDecoder(); return; } setState(() => _videoOpacityTarget = false); diff --git a/pubspec.yaml b/pubspec.yaml index f868828..0c92430 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pets_hero_ai description: PetsHero AI Application. publish_to: 'none' -version: 1.1.13+24 +version: 1.1.14+25 environment: sdk: '>=3.0.0 <4.0.0' @@ -37,6 +37,9 @@ dependencies: android_id: ^0.5.1 visibility_detector: ^0.4.0+2 photo_manager: ^3.9.0 + image: ^4.5.4 + camera: ^0.12.0+1 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: