Compare commits
No commits in common. "2ae2c19024258b1b6f38d66f5ec9484b3fa3f79a" and "20d43b4ae49cfb8342076bd3f4123a87159b17a4" have entirely different histories.
2ae2c19024
...
20d43b4ae4
@ -1,14 +1,8 @@
|
|||||||
<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" />
|
||||||
<!-- 与 camera_android_camerax 中 maxSdkVersion=28 合并冲突:沿用本应用上限 API 32 -->
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="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,12 +12,6 @@
|
|||||||
@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
|
||||||
@ -54,12 +48,6 @@
|
|||||||
@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
|
||||||
@ -112,14 +100,12 @@
|
|||||||
|
|
||||||
+ (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,27 +127,6 @@ 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?;
|
||||||
|
|||||||
@ -6,10 +6,6 @@ abstract final class AppColors {
|
|||||||
static const Color primary = Color(0xFF8B5CF6);
|
static const Color primary = Color(0xFF8B5CF6);
|
||||||
static const Color primaryLight = Color(0x338B5CF6); // #8B5CF620
|
static const Color primaryLight = Color(0x338B5CF6); // #8B5CF620
|
||||||
static const Color primaryShadow = Color(0x338B5CF6); // #8B5CF620 for shadow
|
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
|
// Neutrals
|
||||||
static const Color background = Color(0xFFFAFAFA);
|
static const Color background = Color(0xFFFAFAFA);
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -36,14 +35,12 @@ 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.08;
|
static const double _videoVisibilityThreshold = 0.15;
|
||||||
/// 一屏约 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 = {};
|
||||||
Timer? _visibilityDebounce;
|
bool _visibilityReconcileScheduled = false;
|
||||||
|
|
||||||
/// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow],
|
/// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow],
|
||||||
/// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。
|
/// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。
|
||||||
@ -88,7 +85,6 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_visibilityDebounce?.cancel();
|
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -109,30 +105,26 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
} else {
|
} else {
|
||||||
_cardVisibleFraction.remove(index);
|
_cardVisibleFraction.remove(index);
|
||||||
}
|
}
|
||||||
// 防抖:滚动时可见性回调极频繁,立刻 setState 会让 VideoCard / FutureBuilder 反复重建导致闪屏
|
if (_visibilityReconcileScheduled) return;
|
||||||
_visibilityDebounce?.cancel();
|
_visibilityReconcileScheduled = true;
|
||||||
_visibilityDebounce = Timer(const Duration(milliseconds: 120), () {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_visibilityReconcileScheduled = false;
|
||||||
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
|
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reconcileVisibleVideoIndicesFromDetector() {
|
void _reconcileVisibleVideoIndicesFromDetector() {
|
||||||
final items = _gridItems;
|
final items = _gridItems;
|
||||||
final scored = <MapEntry<int, double>>[];
|
final next = <int>{};
|
||||||
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) {
|
||||||
scored.add(MapEntry(i, e.value));
|
next.add(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
||||||
@ -281,7 +273,6 @@ 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,
|
||||||
@ -333,7 +324,6 @@ 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 ??
|
||||||
@ -362,7 +352,6 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
.add(index)),
|
.add(index)),
|
||||||
onGenerateSimilar: openResult,
|
onGenerateSimilar: openResult,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -375,45 +364,20 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoThumbnailCover extends StatefulWidget {
|
class _VideoThumbnailCover extends StatelessWidget {
|
||||||
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: _thumbFuture,
|
future: VideoThumbnailCache.instance.getThumbnail(videoUrl),
|
||||||
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,37 +15,6 @@ 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,5 +1,4 @@
|
|||||||
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';
|
||||||
@ -9,20 +8,17 @@ 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
|
||||||
@ -42,10 +38,6 @@ 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;
|
||||||
@ -85,7 +77,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 || _isPickingMedia) return;
|
if (_isGenerating) return;
|
||||||
|
|
||||||
final userCredits = UserState.credits.value ?? 0;
|
final userCredits = UserState.credits.value ?? 0;
|
||||||
if (userCredits < _currentCredits) {
|
if (userCredits < _currentCredits) {
|
||||||
@ -95,11 +87,6 @@ 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,
|
||||||
@ -109,48 +96,10 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
child: const AlbumPickerSheet(),
|
child: const AlbumPickerSheet(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (path == null || path.isEmpty || !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 {
|
||||||
@ -159,14 +108,8 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
try {
|
try {
|
||||||
await AuthService.loginComplete;
|
await AuthService.loginComplete;
|
||||||
|
|
||||||
final toUpload = await compressImageForUpload(
|
final size = await file.length();
|
||||||
file,
|
final ext = file.path.split('.').last.toLowerCase();
|
||||||
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'
|
||||||
@ -209,7 +152,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
headers['Content-Type'] = contentType;
|
headers['Content-Type'] = contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
final bytes = await toUpload.readAsBytes();
|
final bytes = await file.readAsBytes();
|
||||||
final uploadResponse = await http.put(
|
final uploadResponse = await http.put(
|
||||||
Uri.parse(uploadUrl),
|
Uri.parse(uploadUrl),
|
||||||
headers: headers,
|
headers: headers,
|
||||||
@ -272,67 +215,44 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final topInset = MediaQuery.paddingOf(context).top;
|
|
||||||
final creditsDisplay =
|
|
||||||
UserCreditsData.of(context)?.creditsDisplay ?? '--';
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBodyBehindAppBar: true,
|
backgroundColor: AppColors.background,
|
||||||
backgroundColor: Colors.black,
|
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
preferredSize: Size.fromHeight(topInset + 56),
|
preferredSize: const Size.fromHeight(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(
|
child: TopNavBar(
|
||||||
title: 'Generate',
|
title: 'Generate',
|
||||||
credits: creditsDisplay,
|
|
||||||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
onBack: () => Navigator.of(context).pop(),
|
onBack: () => Navigator.of(context).pop(),
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
foregroundColor: AppColors.surface,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
body: Column(
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
children: [
|
||||||
_GenerateBackgroundLayer(
|
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,
|
videoUrl: widget.task?.previewVideoUrl,
|
||||||
imageUrl: widget.task?.previewImageUrl,
|
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(
|
SafeArea(
|
||||||
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.screenPaddingLarge,
|
AppSpacing.screenPaddingLarge,
|
||||||
@ -341,21 +261,18 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
AppSpacing.screenPaddingLarge,
|
AppSpacing.screenPaddingLarge,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
|
||||||
if (_hasVideo)
|
if (_hasVideo)
|
||||||
Center(
|
_ResolutionToggle(
|
||||||
child: _ResolutionToggle(
|
|
||||||
selected: _selectedResolution,
|
selected: _selectedResolution,
|
||||||
onChanged: (r) =>
|
onChanged: (r) => setState(() => _selectedResolution = r),
|
||||||
setState(() => _selectedResolution = r),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
|
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
|
||||||
_GenerateButton(
|
_GenerateButton(
|
||||||
onGenerate: _onGenerateButtonTap,
|
onGenerate: _onGenerateButtonTap,
|
||||||
isLoading: _isGenerating || _isPickingMedia,
|
isLoading: _isGenerating,
|
||||||
credits: _currentCredits.toString(),
|
credits: _currentCredits.toString(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -368,281 +285,337 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成页背景:有预览视频则循环播放;打开应用内相机时暂停并显示静帧,避免与 CameraPreview 冲突。
|
class _CreditsCard extends StatelessWidget {
|
||||||
class _GenerateBackgroundLayer extends StatefulWidget {
|
const _CreditsCard({required this.credits});
|
||||||
const _GenerateBackgroundLayer({
|
|
||||||
|
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({
|
||||||
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<_GenerateBackgroundLayer> createState() =>
|
State<_VideoPreviewArea> createState() => _VideoPreviewAreaState();
|
||||||
_GenerateBackgroundLayerState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GenerateBackgroundLayerState extends State<_GenerateBackgroundLayer> {
|
class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
|
||||||
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();
|
||||||
_loadVideoPosterIfNeeded();
|
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
|
||||||
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 _GenerateBackgroundLayer oldWidget) {
|
void didUpdateWidget(covariant _VideoPreviewArea 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 &&
|
if (widget.videoUrl != null && widget.videoUrl!.isNotEmpty) {
|
||||||
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;
|
|
||||||
|
|
||||||
final myGen = ++_videoLoadGen;
|
setState(() {});
|
||||||
if (mounted) setState(() {});
|
|
||||||
|
|
||||||
VideoPlayerController? created;
|
|
||||||
try {
|
try {
|
||||||
final file = await DefaultCacheManager().getSingleFile(url);
|
final file = await DefaultCacheManager().getSingleFile(url);
|
||||||
if (!mounted || myGen != _videoLoadGen) return;
|
if (!mounted) return;
|
||||||
created = VideoPlayerController.file(
|
final controller = VideoPlayerController.file(file);
|
||||||
file,
|
await controller.initialize();
|
||||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
|
if (!mounted) return;
|
||||||
);
|
await controller.play();
|
||||||
await created.initialize();
|
controller.setLooping(true);
|
||||||
if (!mounted || myGen != _videoLoadGen) return;
|
if (mounted) {
|
||||||
await created.setVolume(1.0);
|
setState(() {
|
||||||
await created.setLooping(true);
|
_controller = controller;
|
||||||
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) {
|
} 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 suspended = widget.suspendVideoPlayback;
|
final isReady = _controller != null && _controller!.value.isInitialized;
|
||||||
final videoUrl = widget.videoUrl;
|
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
|
||||||
final hasVideoUrl = videoUrl != null && videoUrl.isNotEmpty;
|
final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
|
||||||
final hasImageUrl = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
|
|
||||||
final videoReady = !suspended &&
|
|
||||||
_controller != null &&
|
|
||||||
_controller!.value.isInitialized;
|
|
||||||
|
|
||||||
if (hasImageUrl && !hasVideoUrl) {
|
// 图片模式:宽度=组件宽度,高度按图片宽高比自适应
|
||||||
return CachedNetworkImage(
|
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!,
|
imageUrl: widget.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
maxWidth: constraints.maxWidth,
|
||||||
width: double.infinity,
|
),
|
||||||
height: double.infinity,
|
);
|
||||||
placeholder: (_, __) => const _BgLoadingPlaceholder(),
|
},
|
||||||
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suspended && hasVideoUrl) {
|
// 视频模式:aspect ratio 来自视频或 16:9 占位
|
||||||
if (hasImageUrl) {
|
final aspectRatio = isReady &&
|
||||||
return CachedNetworkImage(
|
_controller!.value.size.width > 0 &&
|
||||||
imageUrl: widget.imageUrl!,
|
_controller!.value.size.height > 0
|
||||||
fit: BoxFit.cover,
|
? _controller!.value.size.width / _controller!.value.size.height
|
||||||
width: double.infinity,
|
: 16 / 9;
|
||||||
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 LayoutBuilder(
|
||||||
return ClipRect(
|
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(
|
child: FittedBox(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: _controller!.value.size.width,
|
width: _controller!.value.size.width,
|
||||||
height: _controller!.value.size.height,
|
height: _controller!.value.size.height,
|
||||||
child: VideoPlayer(_controller!),
|
child: VideoPlayer(_controller!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
}
|
else if (hasImage)
|
||||||
|
CachedNetworkImage(
|
||||||
if (hasVideoUrl && hasImageUrl) {
|
|
||||||
return CachedNetworkImage(
|
|
||||||
imageUrl: widget.imageUrl!,
|
imageUrl: widget.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
placeholder: (_, __) => const _BgLoadingPlaceholder(),
|
placeholder: (_, __) =>
|
||||||
errorWidget: (_, __, ___) => const _BgErrorPlaceholder(),
|
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 (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 {
|
/// 图片展示:宽度=组件宽度,高度按图片宽高比自适应
|
||||||
const _BgLoadingPlaceholder();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoadingOverlay extends StatelessWidget {
|
||||||
|
const _LoadingOverlay({this.isLoading = true});
|
||||||
|
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.surfaceAlt,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: const SizedBox(
|
child: isLoading
|
||||||
|
? const SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: AppColors.surface,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
);
|
: const Icon(
|
||||||
}
|
LucideIcons.video,
|
||||||
}
|
size: 48,
|
||||||
|
color: AppColors.textMuted,
|
||||||
class _BgErrorPlaceholder extends StatelessWidget {
|
|
||||||
const _BgErrorPlaceholder();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Icon(
|
|
||||||
LucideIcons.image_off,
|
|
||||||
size: 56,
|
|
||||||
color: AppColors.surface.withValues(alpha: 0.45),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -671,7 +644,7 @@ class _ResolutionToggle extends StatelessWidget {
|
|||||||
'Resolution',
|
'Resolution',
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.surface,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -712,22 +685,14 @@ class _ResolutionOption extends StatelessWidget {
|
|||||||
height: 36,
|
height: 36,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
|
||||||
? AppColors.primaryGlass
|
|
||||||
: AppColors.surface.withValues(alpha: 0.18),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: isSelected
|
border:
|
||||||
? Border.all(
|
isSelected ? null : Border.all(color: AppColors.border, width: 1),
|
||||||
color: AppColors.primary.withValues(alpha: 0.42),
|
|
||||||
width: 1)
|
|
||||||
: Border.all(
|
|
||||||
color: AppColors.surface.withValues(alpha: 0.45),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
boxShadow: isSelected
|
boxShadow: isSelected
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryShadow.withValues(alpha: 0.22),
|
color: AppColors.primaryShadow.withValues(alpha: 0.19),
|
||||||
blurRadius: 4,
|
blurRadius: 4,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@ -739,9 +704,7 @@ class _ResolutionOption extends StatelessWidget {
|
|||||||
label,
|
label,
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: isSelected
|
color: isSelected ? AppColors.surface : AppColors.textSecondary,
|
||||||
? AppColors.surface
|
|
||||||
: AppColors.surface.withValues(alpha: 0.85),
|
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -770,15 +733,11 @@ class _GenerateButton extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
height: 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryGlassEmphasis,
|
color: AppColors.primary,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.primary.withValues(alpha: 0.45),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryShadow.withValues(alpha: 0.28),
|
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,219 +0,0 @@
|
|||||||
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,17 +1,13 @@
|
|||||||
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});
|
||||||
@ -33,8 +29,6 @@ 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() {
|
||||||
@ -61,7 +55,6 @@ 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(() {
|
||||||
@ -85,25 +78,12 @@ 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;
|
||||||
|
|
||||||
@ -116,22 +96,6 @@ 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;
|
||||||
@ -167,9 +131,15 @@ 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;
|
||||||
// 不在弹层内调 pickImage:BottomSheet 与相机 Activity 叠加时,返回后 Surface 有概率无法恢复(全黑)。
|
if (x != null) {
|
||||||
Navigator.of(context).pop<String>(kAlbumPickerRequestCamera);
|
Navigator.of(context).pop<String>(x.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAsset(AssetEntity e) async {
|
Future<void> _onAsset(AssetEntity e) async {
|
||||||
@ -178,25 +148,15 @@ 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(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text('Could not open this photo. Try another.'),
|
||||||
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;
|
||||||
|
|
||||||
@ -274,17 +234,6 @@ 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,
|
||||||
@ -334,107 +283,6 @@ 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,10 +101,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_loadCategories();
|
_loadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 与 Gallery 一致:边缘格子可见比例偏低时也应参与自动播
|
static const double _videoVisibilityThreshold = 0.15;
|
||||||
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;
|
||||||
@ -123,21 +120,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
void _reconcileVisibleVideoIndicesFromDetector() {
|
void _reconcileVisibleVideoIndicesFromDetector() {
|
||||||
final tasks = _displayTasks;
|
final tasks = _displayTasks;
|
||||||
final scored = <MapEntry<int, double>>[];
|
final next = <int>{};
|
||||||
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) {
|
||||||
scored.add(MapEntry(i, e.value));
|
next.add(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
||||||
@ -281,17 +273,10 @@ 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(() {
|
||||||
@ -301,9 +286,6 @@ 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);
|
||||||
}
|
}
|
||||||
@ -312,17 +294,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
static const _placeholderImage =
|
static const _placeholderImage =
|
||||||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
|
'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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -371,7 +342,6 @@ 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,
|
||||||
@ -397,7 +367,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_placeholderImage,
|
_placeholderImage,
|
||||||
videoUrl: task.previewVideoUrl,
|
videoUrl: task.previewVideoUrl,
|
||||||
credits: credits,
|
credits: credits,
|
||||||
isActive: widget.isActive &&
|
isActive:
|
||||||
_visibleVideoIndices.contains(index) &&
|
_visibleVideoIndices.contains(index) &&
|
||||||
!_userPausedVideoIndices
|
!_userPausedVideoIndices
|
||||||
.contains(index),
|
.contains(index),
|
||||||
@ -406,7 +376,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
onStopRequested: () => setState(() =>
|
onStopRequested: () => setState(() =>
|
||||||
_userPausedVideoIndices.add(index)),
|
_userPausedVideoIndices.add(index)),
|
||||||
onGenerateSimilar: () =>
|
onGenerateSimilar: () =>
|
||||||
_openGeneratePage(task),
|
Navigator.of(context).pushNamed(
|
||||||
|
'/generate',
|
||||||
|
arguments: task,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -72,18 +72,15 @@ 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) {
|
||||||
_releaseVideoDecoder();
|
_stop();
|
||||||
} else if (!oldWidget.isActive && widget.isActive) {
|
} else if (!oldWidget.isActive && widget.isActive) {
|
||||||
_loadAndPlay();
|
_loadAndPlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仅 pause 会占用 MediaCodec;滑出网格后必须 [dispose] ExoPlayer,否则多划几次后 OMX 解码器耗尽报错。
|
void _stop() {
|
||||||
void _releaseVideoDecoder() {
|
|
||||||
_loadGen++;
|
|
||||||
_controller?.removeListener(_onVideoUpdate);
|
_controller?.removeListener(_onVideoUpdate);
|
||||||
_disposeController();
|
_controller?.pause();
|
||||||
_clearBottomProgress();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_videoOpacityTarget = false;
|
_videoOpacityTarget = false;
|
||||||
@ -166,7 +163,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) {
|
||||||
_releaseVideoDecoder();
|
_stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _videoOpacityTarget = false);
|
setState(() => _videoOpacityTarget = false);
|
||||||
|
|||||||
@ -10,23 +10,13 @@ class CreditsBadge extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.credits,
|
required this.credits,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.foregroundColor,
|
|
||||||
this.capsuleColor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String credits;
|
final String credits;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
/// 图标与数字颜色;默认 [AppColors.primary]
|
|
||||||
final Color? foregroundColor;
|
|
||||||
/// 胶囊背景;默认 [AppColors.primaryLight]
|
|
||||||
final Color? capsuleColor;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fg = foregroundColor ?? AppColors.primary;
|
|
||||||
final capsule = capsuleColor ?? AppColors.primaryLight;
|
|
||||||
final lightOnDarkNav = foregroundColor != null;
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -35,17 +25,9 @@ class CreditsBadge extends StatelessWidget {
|
|||||||
vertical: AppSpacing.sm,
|
vertical: AppSpacing.sm,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: capsule,
|
color: AppColors.primaryLight,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
boxShadow: lightOnDarkNav
|
boxShadow: [
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.28),
|
|
||||||
blurRadius: 6,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryShadow.withValues(alpha: 0.13),
|
color: AppColors.primaryShadow.withValues(alpha: 0.13),
|
||||||
blurRadius: 6,
|
blurRadius: 6,
|
||||||
@ -56,14 +38,15 @@ class CreditsBadge extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.sparkles, size: 16, color: fg),
|
const Icon(LucideIcons.sparkles,
|
||||||
|
size: 16, color: AppColors.primary),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Text(
|
Text(
|
||||||
credits,
|
credits,
|
||||||
style: AppTypography.bodyRegular.copyWith(
|
style: AppTypography.bodyRegular.copyWith(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: fg,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -14,8 +14,6 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
this.showBackButton = false,
|
this.showBackButton = false,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
this.onCreditsTap,
|
this.onCreditsTap,
|
||||||
this.backgroundColor = AppColors.surface,
|
|
||||||
this.foregroundColor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@ -23,29 +21,18 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
final bool showBackButton;
|
final bool showBackButton;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final VoidCallback? onCreditsTap;
|
final VoidCallback? onCreditsTap;
|
||||||
/// 例如全屏背景页上叠半透明导航栏时用 [Colors.transparent]
|
|
||||||
final Color backgroundColor;
|
|
||||||
/// 标题与返回键颜色;默认 [AppColors.textPrimary]
|
|
||||||
final Color? foregroundColor;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => const Size.fromHeight(56);
|
Size get preferredSize => const Size.fromHeight(56);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fg = foregroundColor ?? AppColors.textPrimary;
|
|
||||||
final titleStyle = foregroundColor != null
|
|
||||||
? AppTypography.navTitle.copyWith(color: foregroundColor)
|
|
||||||
: AppTypography.navTitle;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 56,
|
height: 56,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.screenPadding),
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: backgroundColor,
|
color: AppColors.surface,
|
||||||
boxShadow: backgroundColor.a < 0.02
|
boxShadow: [
|
||||||
? null
|
|
||||||
: const [
|
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.shadowLight,
|
color: AppColors.shadowLight,
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
@ -59,14 +46,14 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onBack ?? () => Navigator.of(context).pop(),
|
onTap: onBack ?? () => Navigator.of(context).pop(),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: SizedBox(
|
child: const SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
LucideIcons.arrow_left,
|
LucideIcons.arrow_left,
|
||||||
size: 24,
|
size: 24,
|
||||||
color: fg,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -76,25 +63,19 @@ class TopNavBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: titleStyle,
|
style: AppTypography.navTitle,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Align(
|
: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: titleStyle,
|
style: AppTypography.navTitle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (credits != null)
|
if (credits != null)
|
||||||
CreditsBadge(
|
CreditsBadge(credits: credits!, onTap: onCreditsTap)
|
||||||
credits: credits!,
|
|
||||||
onTap: onCreditsTap,
|
|
||||||
foregroundColor: foregroundColor,
|
|
||||||
capsuleColor:
|
|
||||||
foregroundColor?.withValues(alpha: 0.22),
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
const SizedBox(width: 40),
|
const SizedBox(width: 40),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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.14+25
|
version: 1.1.13+24
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@ -37,9 +37,6 @@ 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