Compare commits
3 Commits
20d43b4ae4
...
2ae2c19024
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae2c19024 | ||
|
|
9f88b80501 | ||
|
|
846dd5e9f5 |
@ -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.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<!-- 与 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" />
|
||||
<application
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
|
||||
@ -12,6 +12,12 @@
|
||||
@import adjust_sdk;
|
||||
#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>)
|
||||
#import <device_info_plus/FPPDeviceInfoPlusPlugin.h>
|
||||
#else
|
||||
@ -48,6 +54,12 @@
|
||||
@import in_app_purchase_storekit;
|
||||
#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>)
|
||||
#import <photo_manager/PhotoManagerPlugin.h>
|
||||
#else
|
||||
@ -100,12 +112,14 @@
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
|
||||
[CameraPlugin registerWithRegistrar:[registry registrarForPlugin:@"CameraPlugin"]];
|
||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||
[FacebookAppEventsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FacebookAppEventsPlugin"]];
|
||||
[FlutterNativeSplashPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeSplashPlugin"]];
|
||||
[GalPlugin registerWithRegistrar:[registry registrarForPlugin:@"GalPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
||||
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
|
||||
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
|
||||
@ -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(是否开启三方支付)
|
||||
static void _saveCommonInfoToState(Map<String, dynamic> data) {
|
||||
final reveal = data['reveal'] as int?;
|
||||
|
||||
@ -6,6 +6,10 @@ abstract final class AppColors {
|
||||
static const Color primary = Color(0xFF8B5CF6);
|
||||
static const Color primaryLight = Color(0x338B5CF6); // #8B5CF620
|
||||
static const Color primaryShadow = Color(0x338B5CF6); // #8B5CF620 for shadow
|
||||
/// 底栏选中、分辨率 chip 等:透出底层,字仍用 [surface] 可读
|
||||
static Color get primaryGlass => primary.withValues(alpha: 0.58);
|
||||
/// 生成页主按钮:略不透明,保证主操作仍醒目
|
||||
static Color get primaryGlassEmphasis => primary.withValues(alpha: 0.72);
|
||||
|
||||
// Neutrals
|
||||
static const Color background = Color(0xFFFAFAFA);
|
||||
|
||||
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 'package:flutter/material.dart';
|
||||
@ -35,12 +36,14 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static const int _pageSize = 20;
|
||||
|
||||
/// 与首页一致:足够可见比例的格子才自动静音循环播视频(允许多个)
|
||||
static const double _videoVisibilityThreshold = 0.15;
|
||||
/// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」
|
||||
static const double _videoVisibilityThreshold = 0.08;
|
||||
/// 一屏约 2×(3~4) 个格子;不再强行只播 2 路(会总挑中间行)。滑出后 [VideoCard] 会释放解码器,此处仅防极端滚动时实例过多
|
||||
static const int _maxConcurrentGalleryVideos = 16;
|
||||
Set<int> _visibleVideoIndices = {};
|
||||
final Set<int> _userPausedVideoIndices = {};
|
||||
final Map<int, double> _cardVisibleFraction = {};
|
||||
bool _visibilityReconcileScheduled = false;
|
||||
Timer? _visibilityDebounce;
|
||||
|
||||
/// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow],
|
||||
/// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。
|
||||
@ -85,6 +88,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_visibilityDebounce?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@ -105,26 +109,30 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
} else {
|
||||
_cardVisibleFraction.remove(index);
|
||||
}
|
||||
if (_visibilityReconcileScheduled) return;
|
||||
_visibilityReconcileScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_visibilityReconcileScheduled = false;
|
||||
// 防抖:滚动时可见性回调极频繁,立刻 setState 会让 VideoCard / FutureBuilder 反复重建导致闪屏
|
||||
_visibilityDebounce?.cancel();
|
||||
_visibilityDebounce = Timer(const Duration(milliseconds: 120), () {
|
||||
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
|
||||
});
|
||||
}
|
||||
|
||||
void _reconcileVisibleVideoIndicesFromDetector() {
|
||||
final items = _gridItems;
|
||||
final next = <int>{};
|
||||
final scored = <MapEntry<int, double>>[];
|
||||
for (final e in _cardVisibleFraction.entries) {
|
||||
final i = e.key;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
if (e.value < _videoVisibilityThreshold) continue;
|
||||
final url = items[i].videoUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
next.add(i);
|
||||
scored.add(MapEntry(i, e.value));
|
||||
}
|
||||
}
|
||||
scored.sort((a, b) => b.value.compareTo(a.value));
|
||||
final next = scored
|
||||
.take(_maxConcurrentGalleryVideos)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
_userPausedVideoIndices.removeWhere((i) => !next.contains(i));
|
||||
if (!_setsEqual(next, _visibleVideoIndices)) {
|
||||
setState(() => _visibleVideoIndices = next);
|
||||
@ -273,6 +281,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
cacheExtent: 800,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xl,
|
||||
@ -324,33 +333,35 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
_onGridCardVisibilityChanged(
|
||||
index, info)
|
||||
: (_) {},
|
||||
child: VideoCard(
|
||||
key: ValueKey(detectorKey),
|
||||
imageUrl: media.imageUrl ??
|
||||
videoUrl ??
|
||||
'',
|
||||
cover: hasVideo
|
||||
? _VideoThumbnailCover(
|
||||
videoUrl: media.videoUrl!,
|
||||
)
|
||||
: null,
|
||||
videoUrl: hasVideo ? videoUrl : null,
|
||||
credits: '50',
|
||||
showCreditsBadge: false,
|
||||
showBottomGenerateButton: false,
|
||||
isActive: widget.isActive &&
|
||||
hasVideo &&
|
||||
_visibleVideoIndices
|
||||
.contains(index) &&
|
||||
!_userPausedVideoIndices
|
||||
.contains(index),
|
||||
onPlayRequested: () => setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.remove(index)),
|
||||
onStopRequested: () => setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.add(index)),
|
||||
onGenerateSimilar: openResult,
|
||||
child: RepaintBoundary(
|
||||
child: VideoCard(
|
||||
key: ValueKey(detectorKey),
|
||||
imageUrl: media.imageUrl ??
|
||||
videoUrl ??
|
||||
'',
|
||||
cover: hasVideo
|
||||
? _VideoThumbnailCover(
|
||||
videoUrl: media.videoUrl!,
|
||||
)
|
||||
: null,
|
||||
videoUrl: hasVideo ? videoUrl : null,
|
||||
credits: '50',
|
||||
showCreditsBadge: false,
|
||||
showBottomGenerateButton: false,
|
||||
isActive: widget.isActive &&
|
||||
hasVideo &&
|
||||
_visibleVideoIndices
|
||||
.contains(index) &&
|
||||
!_userPausedVideoIndices
|
||||
.contains(index),
|
||||
onPlayRequested: () => setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.remove(index)),
|
||||
onStopRequested: () => setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.add(index)),
|
||||
onGenerateSimilar: openResult,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -364,20 +375,45 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoThumbnailCover extends StatelessWidget {
|
||||
class _VideoThumbnailCover extends StatefulWidget {
|
||||
const _VideoThumbnailCover({required this.videoUrl});
|
||||
|
||||
final String videoUrl;
|
||||
|
||||
@override
|
||||
State<_VideoThumbnailCover> createState() => _VideoThumbnailCoverState();
|
||||
}
|
||||
|
||||
class _VideoThumbnailCoverState extends State<_VideoThumbnailCover> {
|
||||
/// 必须在 State 内固定同一 Future;否则父级每次 build 新建 Future,[FutureBuilder] 会重置并闪一下占位图
|
||||
late Future<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
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: VideoThumbnailCache.instance.getThumbnail(videoUrl),
|
||||
future: _thumbFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data != null) {
|
||||
return Image.memory(
|
||||
snapshot.data!,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
|
||||
@ -15,6 +15,37 @@ class VideoThumbnailCache {
|
||||
static const int _maxWidth = 400;
|
||||
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 {
|
||||
final key = _cacheKey(videoUrl);
|
||||
final cacheDir = await _getCacheDir();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -8,17 +9,20 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/util/image_compress.dart';
|
||||
import '../../core/log/app_logger.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../core/user/account_refresh.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../features/gallery/video_thumbnail_cache.dart';
|
||||
import '../../features/home/home_playback_resume.dart';
|
||||
import '../../features/home/models/task_item.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
import '../../core/api/services/image_api.dart';
|
||||
import 'in_app_camera_page.dart';
|
||||
import 'widgets/album_picker_sheet.dart';
|
||||
|
||||
/// Generate Video screen - matches Pencil mmLB5
|
||||
@ -38,6 +42,10 @@ enum _Resolution { p480, p720 }
|
||||
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
_Resolution _selectedResolution = _Resolution.p480;
|
||||
bool _isGenerating = false;
|
||||
/// 防止在选图/相机流程未结束时再次进入,避免并发 native picker / ScreenSecure 错乱
|
||||
bool _isPickingMedia = false;
|
||||
/// 应用内相机打开时暂停背景 [VideoPlayer],避免与 CameraPreview 争用 Surface
|
||||
bool _suspendBackgroundVideo = false;
|
||||
|
||||
int get _currentCredits {
|
||||
final task = widget.task;
|
||||
@ -77,7 +85,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
/// Click flow per docs/generate_video.md: tap Generate Video -> image picker
|
||||
/// (camera or gallery) -> after image selected -> proceed to API.
|
||||
Future<void> _onGenerateButtonTap() async {
|
||||
if (_isGenerating) return;
|
||||
if (_isGenerating || _isPickingMedia) return;
|
||||
|
||||
final userCredits = UserState.credits.value ?? 0;
|
||||
if (userCredits < _currentCredits) {
|
||||
@ -87,19 +95,62 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final path = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => SizedBox(
|
||||
height: MediaQuery.sizeOf(ctx).height * 0.92,
|
||||
child: const AlbumPickerSheet(),
|
||||
),
|
||||
);
|
||||
if (path == null || path.isEmpty || !mounted) return;
|
||||
_isPickingMedia = true;
|
||||
if (mounted) setState(() {});
|
||||
|
||||
final file = File(path);
|
||||
await _runGenerationApi(file);
|
||||
try {
|
||||
await AuthService.runWithNativeMediaPicker(() async {
|
||||
final path = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => SizedBox(
|
||||
height: MediaQuery.sizeOf(ctx).height * 0.92,
|
||||
child: const AlbumPickerSheet(),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (path == kAlbumPickerRequestCamera) {
|
||||
// 应用内相机:推全屏页前暂停背景视频,返回后短暂延迟再恢复,降低 GPU 切换毛刺
|
||||
setState(() => _suspendBackgroundVideo = true);
|
||||
await Future<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);
|
||||
await _runGenerationApi(file);
|
||||
});
|
||||
} finally {
|
||||
_isPickingMedia = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runGenerationApi(File file) async {
|
||||
@ -108,8 +159,14 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
try {
|
||||
await AuthService.loginComplete;
|
||||
|
||||
final size = await file.length();
|
||||
final ext = file.path.split('.').last.toLowerCase();
|
||||
final toUpload = await compressImageForUpload(
|
||||
file,
|
||||
maxSide: 1024,
|
||||
jpegQuality: 75,
|
||||
);
|
||||
|
||||
final size = await toUpload.length();
|
||||
final ext = toUpload.path.split('.').last.toLowerCase();
|
||||
final contentType = ext == 'png'
|
||||
? 'image/png'
|
||||
: ext == 'gif'
|
||||
@ -152,7 +209,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final bytes = await toUpload.readAsBytes();
|
||||
final uploadResponse = await http.put(
|
||||
Uri.parse(uploadUrl),
|
||||
headers: headers,
|
||||
@ -215,44 +272,67 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
final creditsDisplay =
|
||||
UserCreditsData.of(context)?.creditsDisplay ?? '--';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.black,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'Generate',
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
preferredSize: Size.fromHeight(topInset + 56),
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(top: topInset),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.72),
|
||||
Colors.black.withValues(alpha: 0.35),
|
||||
Colors.black.withValues(alpha: 0.0),
|
||||
],
|
||||
stops: const [0.0, 0.55, 1.0],
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: TopNavBar(
|
||||
title: 'Generate',
|
||||
credits: creditsDisplay,
|
||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppColors.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_CreditsCard(
|
||||
credits:
|
||||
UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_VideoPreviewArea(
|
||||
videoUrl: widget.task?.previewVideoUrl,
|
||||
imageUrl: widget.task?.previewImageUrl,
|
||||
),
|
||||
],
|
||||
_GenerateBackgroundLayer(
|
||||
videoUrl: widget.task?.previewVideoUrl,
|
||||
imageUrl: widget.task?.previewImageUrl,
|
||||
suspendVideoPlayback: _suspendBackgroundVideo,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.12),
|
||||
Colors.black.withValues(alpha: 0.55),
|
||||
],
|
||||
stops: const [0.45, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPaddingLarge,
|
||||
@ -261,18 +341,21 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(),
|
||||
if (_hasVideo)
|
||||
_ResolutionToggle(
|
||||
selected: _selectedResolution,
|
||||
onChanged: (r) => setState(() => _selectedResolution = r),
|
||||
Center(
|
||||
child: _ResolutionToggle(
|
||||
selected: _selectedResolution,
|
||||
onChanged: (r) =>
|
||||
setState(() => _selectedResolution = r),
|
||||
),
|
||||
),
|
||||
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
|
||||
_GenerateButton(
|
||||
onGenerate: _onGenerateButtonTap,
|
||||
isLoading: _isGenerating,
|
||||
isLoading: _isGenerating || _isPickingMedia,
|
||||
credits: _currentCredits.toString(),
|
||||
),
|
||||
],
|
||||
@ -285,338 +368,282 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CreditsCard extends StatelessWidget {
|
||||
const _CreditsCard({required this.credits});
|
||||
|
||||
final String credits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxl,
|
||||
vertical: AppSpacing.xl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.5),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.sparkles, size: 28, color: AppColors.surface),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Available Credits',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyLarge.copyWith(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Video preview area - video URL from card click. Auto-load and play on init.
|
||||
/// Video fit: contain (no crop). Loading animation until ready.
|
||||
class _VideoPreviewArea extends StatefulWidget {
|
||||
const _VideoPreviewArea({
|
||||
/// 生成页背景:有预览视频则循环播放;打开应用内相机时暂停并显示静帧,避免与 CameraPreview 冲突。
|
||||
class _GenerateBackgroundLayer extends StatefulWidget {
|
||||
const _GenerateBackgroundLayer({
|
||||
this.videoUrl,
|
||||
this.imageUrl,
|
||||
this.suspendVideoPlayback = false,
|
||||
});
|
||||
|
||||
final String? videoUrl;
|
||||
final String? imageUrl;
|
||||
final bool suspendVideoPlayback;
|
||||
|
||||
@override
|
||||
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState();
|
||||
State<_GenerateBackgroundLayer> createState() =>
|
||||
_GenerateBackgroundLayerState();
|
||||
}
|
||||
|
||||
class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
|
||||
class _GenerateBackgroundLayerState extends State<_GenerateBackgroundLayer> {
|
||||
VideoPlayerController? _controller;
|
||||
int _videoLoadGen = 0;
|
||||
|
||||
Uint8List? _videoPosterBytes;
|
||||
bool _videoPosterLoading = false;
|
||||
|
||||
void _bumpVideoLoadGen() {
|
||||
_videoLoadGen++;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
|
||||
_loadVideoPosterIfNeeded();
|
||||
if (!widget.suspendVideoPlayback &&
|
||||
widget.videoUrl != null &&
|
||||
widget.videoUrl!.isNotEmpty) {
|
||||
_loadAndPlay();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bumpVideoLoadGen();
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _VideoPreviewArea oldWidget) {
|
||||
void didUpdateWidget(covariant _GenerateBackgroundLayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoUrl != widget.videoUrl) {
|
||||
if (oldWidget.videoUrl != widget.videoUrl ||
|
||||
oldWidget.imageUrl != widget.imageUrl) {
|
||||
_videoPosterBytes = null;
|
||||
_videoPosterLoading = false;
|
||||
_loadVideoPosterIfNeeded();
|
||||
_bumpVideoLoadGen();
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
|
||||
if (widget.videoUrl != null &&
|
||||
widget.videoUrl!.isNotEmpty &&
|
||||
!widget.suspendVideoPlayback) {
|
||||
_loadAndPlay();
|
||||
} else if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (widget.suspendVideoPlayback != oldWidget.suspendVideoPlayback) {
|
||||
if (widget.suspendVideoPlayback) {
|
||||
_bumpVideoLoadGen();
|
||||
_controller?.pause();
|
||||
_controller?.dispose();
|
||||
_controller = null;
|
||||
if (mounted) setState(() {});
|
||||
} else if (widget.videoUrl != null &&
|
||||
widget.videoUrl!.isNotEmpty) {
|
||||
_loadAndPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final url = widget.videoUrl;
|
||||
if (url == null || url.isEmpty) return;
|
||||
if (widget.suspendVideoPlayback) return;
|
||||
|
||||
setState(() {});
|
||||
final myGen = ++_videoLoadGen;
|
||||
if (mounted) setState(() {});
|
||||
|
||||
VideoPlayerController? created;
|
||||
try {
|
||||
final file = await DefaultCacheManager().getSingleFile(url);
|
||||
if (!mounted) return;
|
||||
final controller = VideoPlayerController.file(file);
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
await controller.play();
|
||||
controller.setLooping(true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_controller = controller;
|
||||
});
|
||||
if (!mounted || myGen != _videoLoadGen) return;
|
||||
created = VideoPlayerController.file(
|
||||
file,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
|
||||
);
|
||||
await created.initialize();
|
||||
if (!mounted || myGen != _videoLoadGen) return;
|
||||
await created.setVolume(1.0);
|
||||
await created.setLooping(true);
|
||||
await created.play();
|
||||
if (!mounted || myGen != _videoLoadGen) return;
|
||||
if (widget.suspendVideoPlayback) return;
|
||||
final toAttach = created;
|
||||
created = null;
|
||||
if (!mounted || myGen != _videoLoadGen) {
|
||||
await toAttach.dispose();
|
||||
return;
|
||||
}
|
||||
final previous = _controller;
|
||||
setState(() => _controller = toAttach);
|
||||
previous?.dispose();
|
||||
} catch (e) {
|
||||
GenerateVideoScreen._log.e('Video load failed', e);
|
||||
if (mounted) setState(() {});
|
||||
} finally {
|
||||
final orphan = created;
|
||||
if (orphan != null) {
|
||||
await orphan.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isReady = _controller != null && _controller!.value.isInitialized;
|
||||
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
|
||||
final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
|
||||
final suspended = widget.suspendVideoPlayback;
|
||||
final videoUrl = widget.videoUrl;
|
||||
final hasVideoUrl = videoUrl != null && videoUrl.isNotEmpty;
|
||||
final hasImageUrl = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
|
||||
final videoReady = !suspended &&
|
||||
_controller != null &&
|
||||
_controller!.value.isInitialized;
|
||||
|
||||
// 图片模式:宽度=组件宽度,高度按图片宽高比自适应
|
||||
if (!hasVideo && hasImage) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
width: constraints.maxWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.border,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _AspectRatioImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
maxWidth: constraints.maxWidth,
|
||||
),
|
||||
);
|
||||
},
|
||||
if (hasImageUrl && !hasVideoUrl) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => const _BgLoadingPlaceholder(),
|
||||
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
|
||||
);
|
||||
}
|
||||
|
||||
// 视频模式:aspect ratio 来自视频或 16:9 占位
|
||||
final aspectRatio = isReady &&
|
||||
_controller!.value.size.width > 0 &&
|
||||
_controller!.value.size.height > 0
|
||||
? _controller!.value.size.width / _controller!.value.size.height
|
||||
: 16 / 9;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = width / aspectRatio;
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.border,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isReady)
|
||||
SizedBox.expand(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: _controller!.value.size.width,
|
||||
height: _controller!.value.size.height,
|
||||
child: VideoPlayer(_controller!),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (hasImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) =>
|
||||
const _LoadingOverlay(isLoading: true),
|
||||
errorWidget: (_, __, ___) =>
|
||||
const _LoadingOverlay(isLoading: false),
|
||||
)
|
||||
else
|
||||
const _LoadingOverlay(isLoading: false),
|
||||
if (hasVideo && !isReady)
|
||||
const Positioned.fill(
|
||||
child: _LoadingOverlay(isLoading: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (suspended && hasVideoUrl) {
|
||||
if (hasImageUrl) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => const _BgLoadingPlaceholder(),
|
||||
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
final bytes = _videoPosterBytes;
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
}
|
||||
if (_videoPosterLoading) {
|
||||
return const _BgLoadingPlaceholder();
|
||||
}
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
|
||||
if (hasVideoUrl && videoReady) {
|
||||
return ClipRect(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(
|
||||
width: _controller!.value.size.width,
|
||||
height: _controller!.value.size.height,
|
||||
child: VideoPlayer(_controller!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasVideoUrl && hasImageUrl) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => const _BgLoadingPlaceholder(),
|
||||
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasVideoUrl) {
|
||||
final bytes = _videoPosterBytes;
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
}
|
||||
if (_videoPosterLoading) {
|
||||
return const _BgLoadingPlaceholder();
|
||||
}
|
||||
return const _BgLoadingPlaceholder();
|
||||
}
|
||||
|
||||
if (hasImageUrl) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => const _BgLoadingPlaceholder(),
|
||||
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
|
||||
);
|
||||
}
|
||||
|
||||
return const _BgErrorPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
/// 图片展示:宽度=组件宽度,高度按图片宽高比自适应
|
||||
class _AspectRatioImage extends StatefulWidget {
|
||||
const _AspectRatioImage({
|
||||
required this.imageUrl,
|
||||
required this.maxWidth,
|
||||
});
|
||||
|
||||
final String imageUrl;
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
State<_AspectRatioImage> createState() => _AspectRatioImageState();
|
||||
}
|
||||
|
||||
class _AspectRatioImageState extends State<_AspectRatioImage> {
|
||||
double? _aspectRatio;
|
||||
ImageStream? _stream;
|
||||
late ImageStreamListener _listener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listener = ImageStreamListener(_onImageLoaded, onError: _onImageError);
|
||||
_resolveImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _AspectRatioImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.imageUrl != widget.imageUrl) {
|
||||
_stream?.removeListener(_listener);
|
||||
_aspectRatio = null;
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final provider = CachedNetworkImageProvider(widget.imageUrl);
|
||||
_stream = provider.resolve(const ImageConfiguration());
|
||||
_stream!.addListener(_listener);
|
||||
}
|
||||
|
||||
void _onImageLoaded(ImageInfo info, bool sync) {
|
||||
if (!mounted) return;
|
||||
final w = info.image.width.toDouble();
|
||||
final h = info.image.height.toDouble();
|
||||
if (w > 0 && h > 0) {
|
||||
setState(() => _aspectRatio = w / h);
|
||||
}
|
||||
}
|
||||
|
||||
void _onImageError(dynamic exception, StackTrace? stackTrace) {
|
||||
if (mounted) setState(() => _aspectRatio = 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stream?.removeListener(_listener);
|
||||
super.dispose();
|
||||
}
|
||||
class _BgLoadingPlaceholder extends StatelessWidget {
|
||||
const _BgLoadingPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = _aspectRatio ?? 1;
|
||||
final height = widget.maxWidth / ratio;
|
||||
|
||||
return SizedBox(
|
||||
width: widget.maxWidth,
|
||||
height: height,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
width: widget.maxWidth,
|
||||
height: height,
|
||||
placeholder: (_, __) => SizedBox(
|
||||
width: widget.maxWidth,
|
||||
height: widget.maxWidth,
|
||||
child: const _LoadingOverlay(isLoading: true),
|
||||
),
|
||||
errorWidget: (_, __, ___) => SizedBox(
|
||||
width: widget.maxWidth,
|
||||
height: widget.maxWidth,
|
||||
child: const _LoadingOverlay(isLoading: false),
|
||||
return Container(
|
||||
color: AppColors.textPrimary,
|
||||
alignment: Alignment.center,
|
||||
child: const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingOverlay extends StatelessWidget {
|
||||
const _LoadingOverlay({this.isLoading = true});
|
||||
|
||||
final bool isLoading;
|
||||
class _BgErrorPlaceholder extends StatelessWidget {
|
||||
const _BgErrorPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: AppColors.surfaceAlt,
|
||||
color: AppColors.textPrimary,
|
||||
alignment: Alignment.center,
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.video,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.image_off,
|
||||
size: 56,
|
||||
color: AppColors.surface.withValues(alpha: 0.45),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -644,7 +671,7 @@ class _ResolutionToggle extends StatelessWidget {
|
||||
'Resolution',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
fontSize: 14,
|
||||
color: AppColors.textPrimary,
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@ -685,14 +712,22 @@ class _ResolutionOption extends StatelessWidget {
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
|
||||
color: isSelected
|
||||
? AppColors.primaryGlass
|
||||
: AppColors.surface.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border:
|
||||
isSelected ? null : Border.all(color: AppColors.border, width: 1),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.42),
|
||||
width: 1)
|
||||
: Border.all(
|
||||
color: AppColors.surface.withValues(alpha: 0.45),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.19),
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.22),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@ -704,7 +739,9 @@ class _ResolutionOption extends StatelessWidget {
|
||||
label,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
fontSize: 13,
|
||||
color: isSelected ? AppColors.surface : AppColors.textSecondary,
|
||||
color: isSelected
|
||||
? AppColors.surface
|
||||
: AppColors.surface.withValues(alpha: 0.85),
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@ -733,11 +770,15 @@ class _GenerateButton extends StatelessWidget {
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
color: AppColors.primaryGlassEmphasis,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.45),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.28),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
|
||||
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 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_spacing.dart';
|
||||
|
||||
/// [showModalBottomSheet] 返回此值时由**外层页面**调系统相机,避免 BottomSheet 与相机 Activity 叠放导致返回后黑屏/卡死。
|
||||
const String kAlbumPickerRequestCamera = '__album_picker_camera__';
|
||||
|
||||
/// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致)
|
||||
class AlbumPickerSheet extends StatefulWidget {
|
||||
const AlbumPickerSheet({super.key});
|
||||
@ -29,6 +33,8 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
int _loadedPage = -1;
|
||||
int _totalCount = 0;
|
||||
bool _loadingMore = false;
|
||||
/// 最近一次权限结果(用于「仅部分照片」等说明)
|
||||
PermissionState _permissionState = PermissionState.notDetermined;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -55,6 +61,7 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
|
||||
final state = await PhotoManager.requestPermissionExtend();
|
||||
if (!mounted) return;
|
||||
_permissionState = state;
|
||||
|
||||
if (!state.hasAccess) {
|
||||
setState(() {
|
||||
@ -78,12 +85,25 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
_busy = false;
|
||||
_recentAlbum = null;
|
||||
_totalCount = 0;
|
||||
_assets.clear();
|
||||
_loadedPage = -1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_recentAlbum = paths.first;
|
||||
_totalCount = await _recentAlbum!.assetCountAsync;
|
||||
if (!mounted) return;
|
||||
|
||||
if (_totalCount == 0) {
|
||||
setState(() {
|
||||
_assets.clear();
|
||||
_loadedPage = -1;
|
||||
_busy = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize);
|
||||
if (!mounted) return;
|
||||
|
||||
@ -96,6 +116,22 @@ class _AlbumPickerSheetState extends State<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() {
|
||||
if (_loadingMore || _busy || _recentAlbum == null) return;
|
||||
if (_assets.length >= _totalCount) return;
|
||||
@ -131,15 +167,9 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
}
|
||||
|
||||
Future<void> _onCamera() async {
|
||||
final picker = ImagePicker();
|
||||
final x = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (x != null) {
|
||||
Navigator.of(context).pop<String>(x.path);
|
||||
}
|
||||
// 不在弹层内调 pickImage:BottomSheet 与相机 Activity 叠加时,返回后 Surface 有概率无法恢复(全黑)。
|
||||
Navigator.of(context).pop<String>(kAlbumPickerRequestCamera);
|
||||
}
|
||||
|
||||
Future<void> _onAsset(AssetEntity e) async {
|
||||
@ -148,15 +178,25 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
if (f != null) {
|
||||
Navigator.of(context).pop<String>(f.path);
|
||||
} else if (mounted) {
|
||||
final limited = _permissionState == PermissionState.limited;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not open this photo. Try another.'),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
limited
|
||||
? 'Cannot read this photo. It may not be included in your '
|
||||
'current selection. Use "Manage photo access" or choose '
|
||||
'another image.'
|
||||
: 'Could not open this photo. Try another.',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _showNoPhotosHelp =>
|
||||
!_busy && _error == null && _totalCount == 0;
|
||||
|
||||
static const int _crossCount = 3;
|
||||
static const double _spacing = 3;
|
||||
|
||||
@ -234,7 +274,18 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
: _showNoPhotosHelp
|
||||
? _NoPhotosInLibraryPanel(
|
||||
isLimitedAccess:
|
||||
_permissionState == PermissionState.limited,
|
||||
onManageAccess: _openManagePhotoAccess,
|
||||
onOpenSettings: () async {
|
||||
await PhotoManager.openSetting();
|
||||
},
|
||||
onRetry: _init,
|
||||
onUseCamera: _onCamera,
|
||||
)
|
||||
: GridView.builder(
|
||||
controller: _scrollController,
|
||||
cacheExtent: 220,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@ -283,6 +334,107 @@ class _AlbumPickerSheetState extends State<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 {
|
||||
const _CameraGridTile({required this.onTap});
|
||||
|
||||
|
||||
@ -101,7 +101,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_loadCategories();
|
||||
}
|
||||
|
||||
static const double _videoVisibilityThreshold = 0.15;
|
||||
/// 与 Gallery 一致:边缘格子可见比例偏低时也应参与自动播
|
||||
static const double _videoVisibilityThreshold = 0.08;
|
||||
/// 凡达到阈值的格子均可播;滑出后 [VideoCard] 会释放解码器,此处仅防极端情况
|
||||
static const int _maxConcurrentHomeVideos = 16;
|
||||
|
||||
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
|
||||
if (!mounted) return;
|
||||
@ -120,16 +123,21 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
void _reconcileVisibleVideoIndicesFromDetector() {
|
||||
final tasks = _displayTasks;
|
||||
final next = <int>{};
|
||||
final scored = <MapEntry<int, double>>[];
|
||||
for (final e in _cardVisibleFraction.entries) {
|
||||
final i = e.key;
|
||||
if (i < 0 || i >= tasks.length) continue;
|
||||
if (e.value < _videoVisibilityThreshold) continue;
|
||||
final url = tasks[i].previewVideoUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
next.add(i);
|
||||
scored.add(MapEntry(i, e.value));
|
||||
}
|
||||
}
|
||||
scored.sort((a, b) => b.value.compareTo(a.value));
|
||||
final next = scored
|
||||
.take(_maxConcurrentHomeVideos)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
_userPausedVideoIndices.removeWhere((i) => !next.contains(i));
|
||||
if (!_setsEqual(next, _visibleVideoIndices)) {
|
||||
setState(() => _visibleVideoIndices = next);
|
||||
@ -273,10 +281,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
});
|
||||
}
|
||||
setState(() => _tasksLoading = false);
|
||||
// 列表替换后须强制可见性重算,否则探测器有时不回调,预览一直不播
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isActive) _scheduleVisibilityRefresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onTabChanged(CategoryItem c) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
}
|
||||
setState(() => _selectedCategory = c);
|
||||
if (c.id == kExtCategoryId) {
|
||||
setState(() {
|
||||
@ -286,6 +301,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_visibleVideoIndices = {};
|
||||
_cardVisibleFraction.clear();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isActive) _scheduleVisibilityRefresh();
|
||||
});
|
||||
} else {
|
||||
_loadTasks(c.id);
|
||||
}
|
||||
@ -294,6 +312,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
static const _placeholderImage =
|
||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
|
||||
|
||||
/// 与 [GenerateVideoScreen] 默认 480P 消耗一致;不足则去充值,够才进生图页
|
||||
void _openGeneratePage(TaskItem task) {
|
||||
final requiredCredits = task.credits480p ?? 50;
|
||||
final balance = UserState.credits.value ?? 0;
|
||||
if (balance < requiredCredits) {
|
||||
Navigator.of(context).pushNamed('/recharge');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pushNamed('/generate', arguments: task);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -342,6 +371,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
controller: _scrollController,
|
||||
cacheExtent: 800,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
@ -367,19 +397,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_placeholderImage,
|
||||
videoUrl: task.previewVideoUrl,
|
||||
credits: credits,
|
||||
isActive:
|
||||
isActive: widget.isActive &&
|
||||
_visibleVideoIndices.contains(index) &&
|
||||
!_userPausedVideoIndices
|
||||
.contains(index),
|
||||
!_userPausedVideoIndices
|
||||
.contains(index),
|
||||
onPlayRequested: () => setState(() =>
|
||||
_userPausedVideoIndices.remove(index)),
|
||||
onStopRequested: () => setState(() =>
|
||||
_userPausedVideoIndices.add(index)),
|
||||
onGenerateSimilar: () =>
|
||||
Navigator.of(context).pushNamed(
|
||||
'/generate',
|
||||
arguments: task,
|
||||
),
|
||||
_openGeneratePage(task),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -72,15 +72,18 @@ class _VideoCardState extends State<VideoCard> {
|
||||
void didUpdateWidget(covariant VideoCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isActive && !widget.isActive) {
|
||||
_stop();
|
||||
_releaseVideoDecoder();
|
||||
} else if (!oldWidget.isActive && widget.isActive) {
|
||||
_loadAndPlay();
|
||||
}
|
||||
}
|
||||
|
||||
void _stop() {
|
||||
/// 仅 pause 会占用 MediaCodec;滑出网格后必须 [dispose] ExoPlayer,否则多划几次后 OMX 解码器耗尽报错。
|
||||
void _releaseVideoDecoder() {
|
||||
_loadGen++;
|
||||
_controller?.removeListener(_onVideoUpdate);
|
||||
_controller?.pause();
|
||||
_disposeController();
|
||||
_clearBottomProgress();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_videoOpacityTarget = false;
|
||||
@ -163,7 +166,7 @@ class _VideoCardState extends State<VideoCard> {
|
||||
await _controller!.play();
|
||||
if (!mounted || gen != _loadGen) return;
|
||||
if (!widget.isActive) {
|
||||
_stop();
|
||||
_releaseVideoDecoder();
|
||||
return;
|
||||
}
|
||||
setState(() => _videoOpacityTarget = false);
|
||||
|
||||
@ -10,13 +10,23 @@ class CreditsBadge extends StatelessWidget {
|
||||
super.key,
|
||||
required this.credits,
|
||||
this.onTap,
|
||||
this.foregroundColor,
|
||||
this.capsuleColor,
|
||||
});
|
||||
|
||||
final String credits;
|
||||
final VoidCallback? onTap;
|
||||
/// 图标与数字颜色;默认 [AppColors.primary]
|
||||
final Color? foregroundColor;
|
||||
/// 胶囊背景;默认 [AppColors.primaryLight]
|
||||
final Color? capsuleColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = foregroundColor ?? AppColors.primary;
|
||||
final capsule = capsuleColor ?? AppColors.primaryLight;
|
||||
final lightOnDarkNav = foregroundColor != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
@ -25,28 +35,35 @@ class CreditsBadge extends StatelessWidget {
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryLight,
|
||||
color: capsule,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.13),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
boxShadow: lightOnDarkNav
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.28),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.13),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.sparkles,
|
||||
size: 16, color: AppColors.primary),
|
||||
Icon(LucideIcons.sparkles, size: 16, color: fg),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -14,6 +14,8 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.onCreditsTap,
|
||||
this.backgroundColor = AppColors.surface,
|
||||
this.foregroundColor,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@ -21,24 +23,35 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onCreditsTap;
|
||||
/// 例如全屏背景页上叠半透明导航栏时用 [Colors.transparent]
|
||||
final Color backgroundColor;
|
||||
/// 标题与返回键颜色;默认 [AppColors.textPrimary]
|
||||
final Color? foregroundColor;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(56);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = foregroundColor ?? AppColors.textPrimary;
|
||||
final titleStyle = foregroundColor != null
|
||||
? AppTypography.navTitle.copyWith(color: foregroundColor)
|
||||
: AppTypography.navTitle;
|
||||
|
||||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
boxShadow: backgroundColor.a < 0.02
|
||||
? null
|
||||
: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowLight,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@ -46,14 +59,14 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
GestureDetector(
|
||||
onTap: onBack ?? () => Navigator.of(context).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const SizedBox(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
LucideIcons.arrow_left,
|
||||
size: 24,
|
||||
color: AppColors.textPrimary,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -63,19 +76,25 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
? Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.navTitle,
|
||||
style: titleStyle,
|
||||
),
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.navTitle,
|
||||
style: titleStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (credits != null)
|
||||
CreditsBadge(credits: credits!, onTap: onCreditsTap)
|
||||
CreditsBadge(
|
||||
credits: credits!,
|
||||
onTap: onCreditsTap,
|
||||
foregroundColor: foregroundColor,
|
||||
capsuleColor:
|
||||
foregroundColor?.withValues(alpha: 0.22),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
name: pets_hero_ai
|
||||
description: PetsHero AI Application.
|
||||
publish_to: 'none'
|
||||
version: 1.1.13+24
|
||||
version: 1.1.14+25
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@ -37,6 +37,9 @@ dependencies:
|
||||
android_id: ^0.5.1
|
||||
visibility_detector: ^0.4.0+2
|
||||
photo_manager: ^3.9.0
|
||||
image: ^4.5.4
|
||||
camera: ^0.12.0+1
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user