修复:拍照死机问题

This commit is contained in:
ivan 2026-03-29 23:53:24 +08:00
parent 9f88b80501
commit 2ae2c19024
12 changed files with 865 additions and 151 deletions

View File

@ -1,8 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <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.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <!-- 与 camera_android_camerax 中 maxSdkVersion=28 合并冲突:沿用本应用上限 API 32 -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:replace="android:maxSdkVersion" />
<uses-permission android:name="com.android.vending.BILLING" /> <uses-permission android:name="com.android.vending.BILLING" />
<application <application
android:usesCleartextTraffic="${usesCleartextTraffic}" android:usesCleartextTraffic="${usesCleartextTraffic}"

View File

@ -12,6 +12,12 @@
@import adjust_sdk; @import adjust_sdk;
#endif #endif
#if __has_include(<camera_avfoundation/CameraPlugin.h>)
#import <camera_avfoundation/CameraPlugin.h>
#else
@import camera_avfoundation;
#endif
#if __has_include(<device_info_plus/FPPDeviceInfoPlusPlugin.h>) #if __has_include(<device_info_plus/FPPDeviceInfoPlusPlugin.h>)
#import <device_info_plus/FPPDeviceInfoPlusPlugin.h> #import <device_info_plus/FPPDeviceInfoPlusPlugin.h>
#else #else
@ -48,6 +54,12 @@
@import in_app_purchase_storekit; @import in_app_purchase_storekit;
#endif #endif
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
#import <permission_handler_apple/PermissionHandlerPlugin.h>
#else
@import permission_handler_apple;
#endif
#if __has_include(<photo_manager/PhotoManagerPlugin.h>) #if __has_include(<photo_manager/PhotoManagerPlugin.h>)
#import <photo_manager/PhotoManagerPlugin.h> #import <photo_manager/PhotoManagerPlugin.h>
#else #else
@ -100,12 +112,14 @@
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry { + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]]; [AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
[CameraPlugin registerWithRegistrar:[registry registrarForPlugin:@"CameraPlugin"]];
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
[FacebookAppEventsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FacebookAppEventsPlugin"]]; [FacebookAppEventsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FacebookAppEventsPlugin"]];
[FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]]; [FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]];
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]]; [GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]]; [InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]]; [PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]]; [ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];

View File

@ -127,6 +127,27 @@ class AuthService {
} }
} }
/// / Activity [UserState.safeArea]
///
static Future<T> runWithNativeMediaPicker<T>(Future<T> 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 /// common_info surge lucky
static void _saveCommonInfoToState(Map<String, dynamic> data) { static void _saveCommonInfoToState(Map<String, dynamic> data) {
final reveal = data['reveal'] as int?; final reveal = data['reveal'] as int?;

View File

@ -0,0 +1,37 @@
import 'dart:io';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
/// JPEG
Future<File> 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;
}
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -35,12 +36,14 @@ class _GalleryScreenState extends State<GalleryScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
static const int _pageSize = 20; static const int _pageSize = 20;
/// ///
static const double _videoVisibilityThreshold = 0.15; static const double _videoVisibilityThreshold = 0.08;
/// 2×(34) 2 [VideoCard]
static const int _maxConcurrentGalleryVideos = 16;
Set<int> _visibleVideoIndices = {}; Set<int> _visibleVideoIndices = {};
final Set<int> _userPausedVideoIndices = {}; final Set<int> _userPausedVideoIndices = {};
final Map<int, double> _cardVisibleFraction = {}; final Map<int, double> _cardVisibleFraction = {};
bool _visibilityReconcileScheduled = false; Timer? _visibilityDebounce;
/// [IndexedStack] Tab 0 [notifyNow] /// [IndexedStack] Tab 0 [notifyNow]
/// Grid loading /// Grid loading
@ -85,6 +88,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
@override @override
void dispose() { void dispose() {
_visibilityDebounce?.cancel();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@ -105,26 +109,30 @@ class _GalleryScreenState extends State<GalleryScreen> {
} else { } else {
_cardVisibleFraction.remove(index); _cardVisibleFraction.remove(index);
} }
if (_visibilityReconcileScheduled) return; // setState VideoCard / FutureBuilder
_visibilityReconcileScheduled = true; _visibilityDebounce?.cancel();
WidgetsBinding.instance.addPostFrameCallback((_) { _visibilityDebounce = Timer(const Duration(milliseconds: 120), () {
_visibilityReconcileScheduled = false;
if (mounted) _reconcileVisibleVideoIndicesFromDetector(); if (mounted) _reconcileVisibleVideoIndicesFromDetector();
}); });
} }
void _reconcileVisibleVideoIndicesFromDetector() { void _reconcileVisibleVideoIndicesFromDetector() {
final items = _gridItems; final items = _gridItems;
final next = <int>{}; final scored = <MapEntry<int, double>>[];
for (final e in _cardVisibleFraction.entries) { for (final e in _cardVisibleFraction.entries) {
final i = e.key; final i = e.key;
if (i < 0 || i >= items.length) continue; if (i < 0 || i >= items.length) continue;
if (e.value < _videoVisibilityThreshold) continue; if (e.value < _videoVisibilityThreshold) continue;
final url = items[i].videoUrl; final url = items[i].videoUrl;
if (url != null && url.isNotEmpty) { 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)); _userPausedVideoIndices.removeWhere((i) => !next.contains(i));
if (!_setsEqual(next, _visibleVideoIndices)) { if (!_setsEqual(next, _visibleVideoIndices)) {
setState(() => _visibleVideoIndices = next); setState(() => _visibleVideoIndices = next);
@ -273,6 +281,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
physics: physics:
const AlwaysScrollableScrollPhysics(), const AlwaysScrollableScrollPhysics(),
controller: _scrollController, controller: _scrollController,
cacheExtent: 800,
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
AppSpacing.screenPadding, AppSpacing.screenPadding,
AppSpacing.xl, AppSpacing.xl,
@ -324,6 +333,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
_onGridCardVisibilityChanged( _onGridCardVisibilityChanged(
index, info) index, info)
: (_) {}, : (_) {},
child: RepaintBoundary(
child: VideoCard( child: VideoCard(
key: ValueKey(detectorKey), key: ValueKey(detectorKey),
imageUrl: media.imageUrl ?? imageUrl: media.imageUrl ??
@ -352,6 +362,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
.add(index)), .add(index)),
onGenerateSimilar: openResult, onGenerateSimilar: openResult,
), ),
),
); );
}, },
), ),
@ -364,20 +375,45 @@ class _GalleryScreenState extends State<GalleryScreen> {
} }
} }
class _VideoThumbnailCover extends StatelessWidget { class _VideoThumbnailCover extends StatefulWidget {
const _VideoThumbnailCover({required this.videoUrl}); const _VideoThumbnailCover({required this.videoUrl});
final String videoUrl; final String videoUrl;
@override
State<_VideoThumbnailCover> createState() => _VideoThumbnailCoverState();
}
class _VideoThumbnailCoverState extends State<_VideoThumbnailCover> {
/// State Future build Future[FutureBuilder]
late Future<Uint8List?> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>( return FutureBuilder<Uint8List?>(
future: VideoThumbnailCache.instance.getThumbnail(videoUrl), future: _thumbFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) { if (snapshot.hasData && snapshot.data != null) {
return Image.memory( return Image.memory(
snapshot.data!, snapshot.data!,
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true,
); );
} }
return Container( return Container(

View File

@ -15,6 +15,37 @@ class VideoThumbnailCache {
static const int _maxWidth = 400; static const int _maxWidth = 400;
static const int _quality = 75; static const int _quality = 75;
/// ExoPlayer JPEG [maxWidth]
Future<Uint8List?> 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<Uint8List?> getThumbnail(String videoUrl) async { Future<Uint8List?> getThumbnail(String videoUrl) async {
final key = _cacheKey(videoUrl); final key = _cacheKey(videoUrl);
final cacheDir = await _getCacheDir(); final cacheDir = await _getCacheDir();

View File

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart'; import '../../core/auth/auth_service.dart';
import '../../core/util/image_compress.dart';
import '../../core/log/app_logger.dart'; import '../../core/log/app_logger.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 '../../core/user/account_refresh.dart'; import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart'; import '../../core/user/user_state.dart';
import '../../features/gallery/video_thumbnail_cache.dart';
import '../../features/home/home_playback_resume.dart'; import '../../features/home/home_playback_resume.dart';
import '../../features/home/models/task_item.dart'; import '../../features/home/models/task_item.dart';
import '../../shared/widgets/top_nav_bar.dart'; import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/services/image_api.dart'; import '../../core/api/services/image_api.dart';
import 'in_app_camera_page.dart';
import 'widgets/album_picker_sheet.dart'; import 'widgets/album_picker_sheet.dart';
/// Generate Video screen - matches Pencil mmLB5 /// Generate Video screen - matches Pencil mmLB5
@ -38,6 +42,10 @@ enum _Resolution { p480, p720 }
class _GenerateVideoScreenState extends State<GenerateVideoScreen> { class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
_Resolution _selectedResolution = _Resolution.p480; _Resolution _selectedResolution = _Resolution.p480;
bool _isGenerating = false; bool _isGenerating = false;
/// / native picker / ScreenSecure
bool _isPickingMedia = false;
/// [VideoPlayer] CameraPreview Surface
bool _suspendBackgroundVideo = false;
int get _currentCredits { int get _currentCredits {
final task = widget.task; final task = widget.task;
@ -77,7 +85,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
/// Click flow per docs/generate_video.md: tap Generate Video -> image picker /// Click flow per docs/generate_video.md: tap Generate Video -> image picker
/// (camera or gallery) -> after image selected -> proceed to API. /// (camera or gallery) -> after image selected -> proceed to API.
Future<void> _onGenerateButtonTap() async { Future<void> _onGenerateButtonTap() async {
if (_isGenerating) return; if (_isGenerating || _isPickingMedia) return;
final userCredits = UserState.credits.value ?? 0; final userCredits = UserState.credits.value ?? 0;
if (userCredits < _currentCredits) { if (userCredits < _currentCredits) {
@ -87,6 +95,11 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
return; return;
} }
_isPickingMedia = true;
if (mounted) setState(() {});
try {
await AuthService.runWithNativeMediaPicker(() async {
final path = await showModalBottomSheet<String>( final path = await showModalBottomSheet<String>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@ -96,10 +109,48 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
child: const AlbumPickerSheet(), child: const AlbumPickerSheet(),
), ),
); );
if (path == null || path.isEmpty || !mounted) return; if (!mounted) return;
if (path == kAlbumPickerRequestCamera) {
// GPU
setState(() => _suspendBackgroundVideo = true);
await Future<void>.delayed(const Duration(milliseconds: 80));
if (!mounted) return;
final capturedPath = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute<String>(
fullscreenDialog: true,
builder: (context) => const InAppCameraPage(),
),
);
if (mounted) {
await Future<void>.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); final file = File(path);
await _runGenerationApi(file); await _runGenerationApi(file);
});
} finally {
_isPickingMedia = false;
if (mounted) setState(() {});
}
} }
Future<void> _runGenerationApi(File file) async { Future<void> _runGenerationApi(File file) async {
@ -108,8 +159,14 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
try { try {
await AuthService.loginComplete; await AuthService.loginComplete;
final size = await file.length(); final toUpload = await compressImageForUpload(
final ext = file.path.split('.').last.toLowerCase(); file,
maxSide: 1024,
jpegQuality: 75,
);
final size = await toUpload.length();
final ext = toUpload.path.split('.').last.toLowerCase();
final contentType = ext == 'png' final contentType = ext == 'png'
? 'image/png' ? 'image/png'
: ext == 'gif' : ext == 'gif'
@ -152,7 +209,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
headers['Content-Type'] = contentType; headers['Content-Type'] = contentType;
} }
final bytes = await file.readAsBytes(); final bytes = await toUpload.readAsBytes();
final uploadResponse = await http.put( final uploadResponse = await http.put(
Uri.parse(uploadUrl), Uri.parse(uploadUrl),
headers: headers, headers: headers,
@ -255,9 +312,10 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_GenerateFullScreenBackground( _GenerateBackgroundLayer(
videoUrl: widget.task?.previewVideoUrl, videoUrl: widget.task?.previewVideoUrl,
imageUrl: widget.task?.previewImageUrl, imageUrl: widget.task?.previewImageUrl,
suspendVideoPlayback: _suspendBackgroundVideo,
), ),
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: DecoratedBox(
@ -297,7 +355,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
if (_hasVideo) const SizedBox(height: AppSpacing.xxl), if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton( _GenerateButton(
onGenerate: _onGenerateButtonTap, onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating, isLoading: _isGenerating || _isPickingMedia,
credits: _currentCredits.toString(), credits: _currentCredits.toString(),
), ),
], ],
@ -310,104 +368,195 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
} }
} }
/// / [BoxFit.cover] /// CameraPreview
class _GenerateFullScreenBackground extends StatefulWidget { class _GenerateBackgroundLayer extends StatefulWidget {
const _GenerateFullScreenBackground({ const _GenerateBackgroundLayer({
this.videoUrl, this.videoUrl,
this.imageUrl, this.imageUrl,
this.suspendVideoPlayback = false,
}); });
final String? videoUrl; final String? videoUrl;
final String? imageUrl; final String? imageUrl;
final bool suspendVideoPlayback;
@override @override
State<_GenerateFullScreenBackground> createState() => State<_GenerateBackgroundLayer> createState() =>
_GenerateFullScreenBackgroundState(); _GenerateBackgroundLayerState();
} }
class _GenerateFullScreenBackgroundState class _GenerateBackgroundLayerState extends State<_GenerateBackgroundLayer> {
extends State<_GenerateFullScreenBackground> {
VideoPlayerController? _controller; VideoPlayerController? _controller;
int _videoLoadGen = 0;
Uint8List? _videoPosterBytes;
bool _videoPosterLoading = false;
void _bumpVideoLoadGen() {
_videoLoadGen++;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) { _loadVideoPosterIfNeeded();
if (!widget.suspendVideoPlayback &&
widget.videoUrl != null &&
widget.videoUrl!.isNotEmpty) {
_loadAndPlay(); _loadAndPlay();
} }
} }
@override @override
void dispose() { void dispose() {
_bumpVideoLoadGen();
_controller?.dispose(); _controller?.dispose();
_controller = null; _controller = null;
super.dispose(); super.dispose();
} }
@override @override
void didUpdateWidget(covariant _GenerateFullScreenBackground oldWidget) { void didUpdateWidget(covariant _GenerateBackgroundLayer oldWidget) {
super.didUpdateWidget(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?.dispose();
_controller = null; _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(); _loadAndPlay();
} }
} }
} }
Future<void> _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<void> _loadAndPlay() async { Future<void> _loadAndPlay() async {
final url = widget.videoUrl; final url = widget.videoUrl;
if (url == null || url.isEmpty) return; if (url == null || url.isEmpty) return;
if (widget.suspendVideoPlayback) return;
setState(() {}); final myGen = ++_videoLoadGen;
if (mounted) setState(() {});
VideoPlayerController? created;
try { try {
final file = await DefaultCacheManager().getSingleFile(url); final file = await DefaultCacheManager().getSingleFile(url);
if (!mounted) return; if (!mounted || myGen != _videoLoadGen) return;
final controller = VideoPlayerController.file( created = VideoPlayerController.file(
file, file,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
); );
await controller.initialize(); await created.initialize();
if (!mounted) return; if (!mounted || myGen != _videoLoadGen) return;
await controller.setVolume(1.0); await created.setVolume(1.0);
await controller.setLooping(true); await created.setLooping(true);
await controller.play(); await created.play();
if (mounted) { if (!mounted || myGen != _videoLoadGen) return;
setState(() { if (widget.suspendVideoPlayback) return;
_controller = controller; final toAttach = created;
}); created = null;
if (!mounted || myGen != _videoLoadGen) {
await toAttach.dispose();
return;
} }
final previous = _controller;
setState(() => _controller = toAttach);
previous?.dispose();
} catch (e) { } catch (e) {
GenerateVideoScreen._log.e('Video load failed', e); GenerateVideoScreen._log.e('Video load failed', e);
if (mounted) setState(() {}); if (mounted) setState(() {});
} finally {
final orphan = created;
if (orphan != null) {
await orphan.dispose();
}
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isReady = _controller != null && _controller!.value.isInitialized; final suspended = widget.suspendVideoPlayback;
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; final videoUrl = widget.videoUrl;
final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty; final hasVideoUrl = videoUrl != null && videoUrl.isNotEmpty;
final hasImageUrl = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
final videoReady = !suspended &&
_controller != null &&
_controller!.value.isInitialized;
return Stack( if (hasImageUrl && !hasVideoUrl) {
fit: StackFit.expand, return CachedNetworkImage(
children: [
if (!hasVideo && hasImage)
Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.imageUrl!, imageUrl: widget.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(), placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
), );
) }
else if (hasVideo && isReady)
Positioned.fill( if (suspended && hasVideoUrl) {
child: ClipRect( 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( child: FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
child: SizedBox( child: SizedBox(
@ -416,26 +565,50 @@ class _GenerateFullScreenBackgroundState
child: VideoPlayer(_controller!), child: VideoPlayer(_controller!),
), ),
), ),
), );
) }
else if (hasVideo && hasImage)
Positioned.fill( if (hasVideoUrl && hasImageUrl) {
child: CachedNetworkImage( return CachedNetworkImage(
imageUrl: widget.imageUrl!, imageUrl: widget.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, __) => const _BgLoadingPlaceholder(), placeholder: (_, __) => const _BgLoadingPlaceholder(),
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(), errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
),
)
else if (hasVideo)
const Positioned.fill(child: _BgLoadingPlaceholder())
else
const Positioned.fill(child: _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();
}
} }
class _BgLoadingPlaceholder extends StatelessWidget { class _BgLoadingPlaceholder extends StatelessWidget {

View File

@ -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<InAppCameraPage> createState() => _InAppCameraPageState();
}
class _InAppCameraPageState extends State<InAppCameraPage> {
CameraController? _controller;
bool _initializing = true;
bool _capturing = false;
String? _error;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareCamera());
}
Future<void> _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<void> _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<String>(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<String>(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),
],
),
),
],
),
),
],
),
);
}
}

View File

@ -1,13 +1,17 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.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';
/// [showModalBottomSheet] **** BottomSheet Activity /
const String kAlbumPickerRequestCamera = '__album_picker_camera__';
/// App /// App
class AlbumPickerSheet extends StatefulWidget { class AlbumPickerSheet extends StatefulWidget {
const AlbumPickerSheet({super.key}); const AlbumPickerSheet({super.key});
@ -29,6 +33,8 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
int _loadedPage = -1; int _loadedPage = -1;
int _totalCount = 0; int _totalCount = 0;
bool _loadingMore = false; bool _loadingMore = false;
///
PermissionState _permissionState = PermissionState.notDetermined;
@override @override
void initState() { void initState() {
@ -55,6 +61,7 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
final state = await PhotoManager.requestPermissionExtend(); final state = await PhotoManager.requestPermissionExtend();
if (!mounted) return; if (!mounted) return;
_permissionState = state;
if (!state.hasAccess) { if (!state.hasAccess) {
setState(() { setState(() {
@ -78,12 +85,25 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
_busy = false; _busy = false;
_recentAlbum = null; _recentAlbum = null;
_totalCount = 0; _totalCount = 0;
_assets.clear();
_loadedPage = -1;
}); });
return; return;
} }
_recentAlbum = paths.first; _recentAlbum = paths.first;
_totalCount = await _recentAlbum!.assetCountAsync; _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); final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize);
if (!mounted) return; if (!mounted) return;
@ -96,6 +116,22 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
}); });
} }
/// iOSAndroid
Future<void> _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() { void _maybeLoadMore() {
if (_loadingMore || _busy || _recentAlbum == null) return; if (_loadingMore || _busy || _recentAlbum == null) return;
if (_assets.length >= _totalCount) return; if (_assets.length >= _totalCount) return;
@ -131,15 +167,9 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
} }
Future<void> _onCamera() async { Future<void> _onCamera() async {
final picker = ImagePicker();
final x = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
if (!mounted) return; if (!mounted) return;
if (x != null) { // pickImageBottomSheet Activity Surface
Navigator.of(context).pop<String>(x.path); Navigator.of(context).pop<String>(kAlbumPickerRequestCamera);
}
} }
Future<void> _onAsset(AssetEntity e) async { Future<void> _onAsset(AssetEntity e) async {
@ -148,15 +178,25 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
if (f != null) { if (f != null) {
Navigator.of(context).pop<String>(f.path); Navigator.of(context).pop<String>(f.path);
} else if (mounted) { } else if (mounted) {
final limited = _permissionState == PermissionState.limited;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Could not open this photo. Try another.'), 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, behavior: SnackBarBehavior.floating,
), ),
); );
} }
} }
bool get _showNoPhotosHelp =>
!_busy && _error == null && _totalCount == 0;
static const int _crossCount = 3; static const int _crossCount = 3;
static const double _spacing = 3; static const double _spacing = 3;
@ -234,6 +274,17 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
), ),
), ),
) )
: _showNoPhotosHelp
? _NoPhotosInLibraryPanel(
isLimitedAccess:
_permissionState == PermissionState.limited,
onManageAccess: _openManagePhotoAccess,
onOpenSettings: () async {
await PhotoManager.openSetting();
},
onRetry: _init,
onUseCamera: _onCamera,
)
: GridView.builder( : GridView.builder(
controller: _scrollController, controller: _scrollController,
cacheExtent: 220, cacheExtent: 220,
@ -283,6 +334,107 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
} }
} }
///
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<void> Function() onManageAccess;
final Future<void> Function() onOpenSettings;
final Future<void> Function() onRetry;
final Future<void> 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 { class _CameraGridTile extends StatelessWidget {
const _CameraGridTile({required this.onTap}); const _CameraGridTile({required this.onTap});

View File

@ -101,7 +101,10 @@ class _HomeScreenState extends State<HomeScreen> {
_loadCategories(); _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) { void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
if (!mounted) return; if (!mounted) return;
@ -120,16 +123,21 @@ class _HomeScreenState extends State<HomeScreen> {
void _reconcileVisibleVideoIndicesFromDetector() { void _reconcileVisibleVideoIndicesFromDetector() {
final tasks = _displayTasks; final tasks = _displayTasks;
final next = <int>{}; final scored = <MapEntry<int, double>>[];
for (final e in _cardVisibleFraction.entries) { for (final e in _cardVisibleFraction.entries) {
final i = e.key; final i = e.key;
if (i < 0 || i >= tasks.length) continue; if (i < 0 || i >= tasks.length) continue;
if (e.value < _videoVisibilityThreshold) continue; if (e.value < _videoVisibilityThreshold) continue;
final url = tasks[i].previewVideoUrl; final url = tasks[i].previewVideoUrl;
if (url != null && url.isNotEmpty) { 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)); _userPausedVideoIndices.removeWhere((i) => !next.contains(i));
if (!_setsEqual(next, _visibleVideoIndices)) { if (!_setsEqual(next, _visibleVideoIndices)) {
setState(() => _visibleVideoIndices = next); setState(() => _visibleVideoIndices = next);
@ -273,10 +281,17 @@ class _HomeScreenState extends State<HomeScreen> {
}); });
} }
setState(() => _tasksLoading = false); setState(() => _tasksLoading = false);
//
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isActive) _scheduleVisibilityRefresh();
});
} }
} }
void _onTabChanged(CategoryItem c) { void _onTabChanged(CategoryItem c) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
setState(() => _selectedCategory = c); setState(() => _selectedCategory = c);
if (c.id == kExtCategoryId) { if (c.id == kExtCategoryId) {
setState(() { setState(() {
@ -286,6 +301,9 @@ class _HomeScreenState extends State<HomeScreen> {
_visibleVideoIndices = {}; _visibleVideoIndices = {};
_cardVisibleFraction.clear(); _cardVisibleFraction.clear();
}); });
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isActive) _scheduleVisibilityRefresh();
});
} else { } else {
_loadTasks(c.id); _loadTasks(c.id);
} }
@ -353,6 +371,7 @@ class _HomeScreenState extends State<HomeScreen> {
AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge,
), ),
controller: _scrollController, controller: _scrollController,
cacheExtent: 800,
gridDelegate: gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount( const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
@ -378,7 +397,7 @@ class _HomeScreenState extends State<HomeScreen> {
_placeholderImage, _placeholderImage,
videoUrl: task.previewVideoUrl, videoUrl: task.previewVideoUrl,
credits: credits, credits: credits,
isActive: isActive: widget.isActive &&
_visibleVideoIndices.contains(index) && _visibleVideoIndices.contains(index) &&
!_userPausedVideoIndices !_userPausedVideoIndices
.contains(index), .contains(index),

View File

@ -72,15 +72,18 @@ class _VideoCardState extends State<VideoCard> {
void didUpdateWidget(covariant VideoCard oldWidget) { void didUpdateWidget(covariant VideoCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.isActive && !widget.isActive) { if (oldWidget.isActive && !widget.isActive) {
_stop(); _releaseVideoDecoder();
} else if (!oldWidget.isActive && widget.isActive) { } else if (!oldWidget.isActive && widget.isActive) {
_loadAndPlay(); _loadAndPlay();
} }
} }
void _stop() { /// pause MediaCodec [dispose] ExoPlayer OMX
void _releaseVideoDecoder() {
_loadGen++;
_controller?.removeListener(_onVideoUpdate); _controller?.removeListener(_onVideoUpdate);
_controller?.pause(); _disposeController();
_clearBottomProgress();
if (mounted) { if (mounted) {
setState(() { setState(() {
_videoOpacityTarget = false; _videoOpacityTarget = false;
@ -163,7 +166,7 @@ class _VideoCardState extends State<VideoCard> {
await _controller!.play(); await _controller!.play();
if (!mounted || gen != _loadGen) return; if (!mounted || gen != _loadGen) return;
if (!widget.isActive) { if (!widget.isActive) {
_stop(); _releaseVideoDecoder();
return; return;
} }
setState(() => _videoOpacityTarget = false); setState(() => _videoOpacityTarget = false);

View File

@ -1,7 +1,7 @@
name: pets_hero_ai name: pets_hero_ai
description: PetsHero AI Application. description: PetsHero AI Application.
publish_to: 'none' publish_to: 'none'
version: 1.1.13+24 version: 1.1.14+25
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@ -37,6 +37,9 @@ dependencies:
android_id: ^0.5.1 android_id: ^0.5.1
visibility_detector: ^0.4.0+2 visibility_detector: ^0.4.0+2
photo_manager: ^3.9.0 photo_manager: ^3.9.0
image: ^4.5.4
camera: ^0.12.0+1
permission_handler: ^12.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: