Compare commits

..

3 Commits

Author SHA1 Message Date
ivan
2ae2c19024 修复:拍照死机问题 2026-03-29 23:53:24 +08:00
ivan
9f88b80501 优化:积分不够的时候跳转到充值页面 2026-03-29 21:06:27 +08:00
ivan
846dd5e9f5 优化:生成图片页面 2026-03-29 20:41:45 +08:00
15 changed files with 1039 additions and 409 deletions

View File

@ -1,8 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.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}"

View File

@ -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"]];

View File

@ -127,6 +127,27 @@ class AuthService {
}
}
/// / Activity [UserState.safeArea]
///
static Future<T> runWithNativeMediaPicker<T>(Future<T> Function() action) async {
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return await action();
}
try {
await ScreenSecure.disableScreenshotBlock();
await ScreenSecure.disableScreenRecordBlock();
_logMsg('native media picker: ScreenSecure released');
} on ScreenSecureException catch (e) {
_logMsg('native media picker: disable failed: ${e.message}');
}
try {
return await action();
} finally {
await _applyScreenSecure(UserState.safeArea.value);
}
}
/// common_info surge lucky
static void _saveCommonInfoToState(Map<String, dynamic> data) {
final reveal = data['reveal'] as int?;

View File

@ -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);

View File

@ -0,0 +1,37 @@
import 'dart:io';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
/// JPEG
Future<File> compressImageForUpload(
File source, {
int maxSide = 2048,
int jpegQuality = 85,
}) async {
try {
final raw = await source.readAsBytes();
final image = img.decodeImage(raw);
if (image == null) return source;
var work = image;
if (work.width > maxSide || work.height > maxSide) {
if (work.width >= work.height) {
work = img.copyResize(work, width: maxSide, interpolation: img.Interpolation.linear);
} else {
work = img.copyResize(work, height: maxSide, interpolation: img.Interpolation.linear);
}
}
final jpg = img.encodeJpg(work, quality: jpegQuality);
final dir = await getTemporaryDirectory();
final out = File(
'${dir.path}/upload_${DateTime.now().millisecondsSinceEpoch}.jpg',
);
await out.writeAsBytes(jpg, flush: true);
return out;
} catch (_) {
return source;
}
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import '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×(34) 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,6 +333,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
_onGridCardVisibilityChanged(
index, info)
: (_) {},
child: RepaintBoundary(
child: VideoCard(
key: ValueKey(detectorKey),
imageUrl: media.imageUrl ??
@ -352,6 +362,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
.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(

View File

@ -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();

View File

@ -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,6 +95,11 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
return;
}
_isPickingMedia = true;
if (mounted) setState(() {});
try {
await AuthService.runWithNativeMediaPicker(() async {
final path = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
@ -96,10 +109,48 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
child: const AlbumPickerSheet(),
),
);
if (path == null || path.isEmpty || !mounted) return;
if (!mounted) return;
if (path == kAlbumPickerRequestCamera) {
// GPU
setState(() => _suspendBackgroundVideo = true);
await Future<void>.delayed(const Duration(milliseconds: 80));
if (!mounted) return;
final capturedPath = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute<String>(
fullscreenDialog: true,
builder: (context) => const InAppCameraPage(),
),
);
if (mounted) {
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
setState(() => _suspendBackgroundVideo = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
}
if (!mounted) return;
if (capturedPath != null && capturedPath.isNotEmpty) {
final f = File(capturedPath);
if (await f.exists()) {
await _runGenerationApi(f);
}
}
return;
}
if (path == null || path.isEmpty) return;
final file = File(path);
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),
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(
_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(
Center(
child: _ResolutionToggle(
selected: _selectedResolution,
onChanged: (r) => setState(() => _selectedResolution = r),
onChanged: (r) =>
setState(() => _selectedResolution = r),
),
),
if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton(
onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating,
isLoading: _isGenerating || _isPickingMedia,
credits: _currentCredits.toString(),
),
],
@ -285,337 +368,281 @@ 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(
if (hasImageUrl && !hasVideoUrl) {
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
maxWidth: constraints.maxWidth,
),
);
},
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;
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);
}
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(
if (hasVideoUrl && videoReady) {
return ClipRect(
child: FittedBox(
fit: BoxFit.contain,
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
)
else if (hasImage)
CachedNetworkImage(
);
}
if (hasVideoUrl && hasImageUrl) {
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.contain,
fit: BoxFit.cover,
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),
),
],
),
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();
}
@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;
class _BgLoadingPlaceholder extends StatelessWidget {
const _BgLoadingPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: AppColors.surfaceAlt,
color: AppColors.textPrimary,
alignment: Alignment.center,
child: isLoading
? const SizedBox(
child: const SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
color: AppColors.surface,
),
)
: 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),
),
);
}
@ -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),
),

View File

@ -0,0 +1,219 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/log/app_logger.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
/// Activity Flutter Surface /
class InAppCameraPage extends StatefulWidget {
const InAppCameraPage({super.key});
static final _log = AppLogger('InAppCamera');
@override
State<InAppCameraPage> createState() => _InAppCameraPageState();
}
class _InAppCameraPageState extends State<InAppCameraPage> {
CameraController? _controller;
bool _initializing = true;
bool _capturing = false;
String? _error;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareCamera());
}
Future<void> _prepareCamera() async {
final status = await Permission.camera.request();
if (!status.isGranted) {
if (mounted) {
setState(() {
_initializing = false;
_error = 'Camera permission is required';
});
}
return;
}
try {
final cameras = await availableCameras();
if (cameras.isEmpty) {
if (mounted) {
setState(() {
_initializing = false;
_error = 'No camera available';
});
}
return;
}
final back = cameras.where((c) => c.lensDirection == CameraLensDirection.back);
final selected = back.isEmpty ? cameras.first : back.first;
final c = CameraController(
selected,
ResolutionPreset.medium,
enableAudio: false,
);
await c.initialize();
if (!mounted) {
await c.dispose();
return;
}
setState(() {
_controller = c;
_initializing = false;
});
} catch (e, st) {
InAppCameraPage._log.e('Camera init failed', e, st);
if (mounted) {
setState(() {
_initializing = false;
_error = 'Could not open camera';
});
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
Future<void> _onShutter() async {
final c = _controller;
if (c == null || !c.value.isInitialized || _capturing) return;
setState(() => _capturing = true);
try {
final shot = await c.takePicture();
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop<String>(shot.path);
} catch (e, st) {
InAppCameraPage._log.e('takePicture failed', e, st);
if (mounted) setState(() => _capturing = false);
}
}
void _onClose() {
Navigator.of(context, rootNavigator: true).pop<String>(null);
}
@override
Widget build(BuildContext context) {
final c = _controller;
final showPreview =
!_initializing && _error == null && c != null && c.value.isInitialized;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
if (showPreview)
Positioned.fill(
child: ColoredBox(
color: Colors.black,
// aspect SizedBox/FittedBox[CameraPreview] 使
// 1/value.aspectRatio + Android [RotatedBox]
child: Center(
child: CameraPreview(c),
),
),
),
SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
if (_initializing)
const Center(
child:
CircularProgressIndicator(color: AppColors.primary),
)
else if (_error != null)
Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.camera_off,
size: 48,
color: AppColors.surface.withValues(alpha: 0.6),
),
const SizedBox(height: AppSpacing.md),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.surface.withValues(alpha: 0.85),
fontSize: 15,
),
),
const SizedBox(height: AppSpacing.lg),
TextButton(
onPressed: _onClose,
child: const Text('Close'),
),
],
),
),
),
if (showPreview)
Positioned(
left: 0,
right: 0,
bottom: 24,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
onPressed: _capturing ? null : _onClose,
icon: Icon(
LucideIcons.x,
color: AppColors.surface.withValues(alpha: 0.9),
size: 28,
),
),
GestureDetector(
onTap: _capturing ? null : _onShutter,
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.surface,
width: 4,
),
color: AppColors.surface.withValues(alpha: 0.2),
),
child: _capturing
? const Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.surface,
),
)
: null,
),
),
const SizedBox(width: 48),
],
),
),
],
),
),
],
),
);
}
}

View File

@ -1,13 +1,17 @@
import 'dart:typed_data';
import '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> {
});
}
/// iOSAndroid
Future<void> _openManagePhotoAccess() async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
try {
await PhotoManager.presentLimited();
} catch (_) {
await PhotoManager.openSetting();
return;
}
} else {
await PhotoManager.openSetting();
return;
}
if (mounted) await _init();
}
void _maybeLoadMore() {
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);
}
// pickImageBottomSheet 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,6 +274,17 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
),
),
)
: _showNoPhotosHelp
? _NoPhotosInLibraryPanel(
isLimitedAccess:
_permissionState == PermissionState.limited,
onManageAccess: _openManagePhotoAccess,
onOpenSettings: () async {
await PhotoManager.openSetting();
},
onRetry: _init,
onUseCamera: _onCamera,
)
: GridView.builder(
controller: _scrollController,
cacheExtent: 220,
@ -283,6 +334,107 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
}
}
///
class _NoPhotosInLibraryPanel extends StatelessWidget {
const _NoPhotosInLibraryPanel({
required this.isLimitedAccess,
required this.onManageAccess,
required this.onOpenSettings,
required this.onRetry,
required this.onUseCamera,
});
final bool isLimitedAccess;
final Future<void> Function() onManageAccess;
final Future<void> Function() onOpenSettings;
final Future<void> Function() onRetry;
final Future<void> Function() onUseCamera;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xl + bottom,
),
child: Column(
children: [
Icon(
LucideIcons.folder_open,
size: 48,
color: AppColors.textSecondary.withValues(alpha: 0.9),
),
const SizedBox(height: AppSpacing.lg),
Text(
isLimitedAccess
? 'No photos shared with this app'
: 'No photos available',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: AppSpacing.md),
Text(
isLimitedAccess
? 'You allowed access to only some photos, or none are '
'selected for this app. Add photos or change permission, '
'then try again.'
: 'We could not load any images. Check photo access in '
'Settings or try again.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
height: 1.4,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.surface,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () => onManageAccess(),
child: Text(
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
? 'Select photos'
: 'Manage photo access',
),
),
),
const SizedBox(height: AppSpacing.sm),
TextButton(
onPressed: () => onOpenSettings(),
child: const Text('Open Settings'),
),
TextButton(
onPressed: () => onRetry(),
child: const Text('Try again'),
),
const SizedBox(height: AppSpacing.md),
TextButton.icon(
onPressed: () => onUseCamera(),
icon: const Icon(LucideIcons.camera, size: 20),
label: const Text('Take photo with camera'),
),
],
),
);
}
}
class _CameraGridTile extends StatelessWidget {
const _CameraGridTile({required this.onTap});

View File

@ -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,7 +397,7 @@ class _HomeScreenState extends State<HomeScreen> {
_placeholderImage,
videoUrl: task.previewVideoUrl,
credits: credits,
isActive:
isActive: widget.isActive &&
_visibleVideoIndices.contains(index) &&
!_userPausedVideoIndices
.contains(index),
@ -376,10 +406,7 @@ class _HomeScreenState extends State<HomeScreen> {
onStopRequested: () => setState(() =>
_userPausedVideoIndices.add(index)),
onGenerateSimilar: () =>
Navigator.of(context).pushNamed(
'/generate',
arguments: task,
),
_openGeneratePage(task),
),
);
},

View File

@ -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);

View File

@ -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,9 +35,17 @@ class CreditsBadge extends StatelessWidget {
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.primaryLight,
color: capsule,
borderRadius: BorderRadius.circular(14),
boxShadow: [
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,
@ -38,15 +56,14 @@ class CreditsBadge extends StatelessWidget {
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,
),
),
],

View File

@ -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,18 +23,29 @@ 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: [
decoration: BoxDecoration(
color: backgroundColor,
boxShadow: backgroundColor.a < 0.02
? null
: const [
BoxShadow(
color: AppColors.shadowLight,
blurRadius: 8,
@ -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),
],

View File

@ -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: