修复:拍照死机问题
This commit is contained in:
parent
9f88b80501
commit
2ae2c19024
@ -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}"
|
||||||
|
|||||||
@ -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"]];
|
||||||
|
|||||||
@ -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?;
|
||||||
|
|||||||
37
lib/core/util/image_compress.dart
Normal file
37
lib/core/util/image_compress.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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×(3~4) 个格子;不再强行只播 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(
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
219
lib/features/generate_video/in_app_camera_page.dart
Normal file
219
lib/features/generate_video/in_app_camera_page.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS:系统「补充照片」面板;Android 等:打开系统设置改权限
|
||||||
|
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) {
|
// 不在弹层内调 pickImage:BottomSheet 与相机 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});
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user