import 'dart:async'; import 'dart:io'; import 'dart:math' show max; import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:visibility_detector/visibility_detector.dart'; import '../../core/api/api_config.dart'; import '../../core/api/services/image_api.dart'; import '../../core/auth/auth_service.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_spacing.dart'; import '../../core/util/device_memory_profile.dart'; import '../../shared/widgets/top_nav_bar.dart'; import '../home/widgets/video_card.dart'; import 'gallery_upload_cover_store.dart'; import 'models/gallery_task_item.dart'; DateTime? _galleryDateTimeFromRaw(int raw) { if (raw <= 0) return null; if (raw > 1000000000000) { return DateTime.fromMillisecondsSinceEpoch(raw); } return DateTime.fromMillisecondsSinceEpoch(raw * 1000); } String? _galleryCardCreatedLine(GalleryMediaItem m) { final uncover = m.createTimeText?.trim(); if (uncover != null && uncover.isNotEmpty) { return 'Created $uncover'; } final dt = _galleryDateTimeFromRaw(m.createTime); if (dt == null) return null; String two(int n) => n.toString().padLeft(2, '0'); return 'Created ${dt.year}-${two(dt.month)}-${two(dt.day)} ' '${two(dt.hour)}:${two(dt.minute)}'; } String? _galleryCardRemainingLine(GalleryMediaItem m) { final dt = _galleryDateTimeFromRaw(m.createTime); if (dt == null) return null; final expires = dt.add(const Duration(hours: 24)); final left = expires.difference(DateTime.now()); if (left.isNegative) return 'Expired'; return '${left.inHours}h ${left.inMinutes.remainder(60)}m left'; } Color _galleryCardRemainingColor(GalleryMediaItem m) { final dt = _galleryDateTimeFromRaw(m.createTime); if (dt == null) return const Color(0xFFE5E7EB); final expires = dt.add(const Duration(hours: 24)); final left = expires.difference(DateTime.now()); if (left.isNegative) return AppColors.textMuted; if (left.inHours < 8) return const Color(0xFFFDE68A); return const Color(0xFFE5E7EB); } /// Pencil「Retention notice」(hpwBg / vmPs1) class _GalleryRetentionBanner extends StatelessWidget { const _GalleryRetentionBanner(); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFFFBEB), Color(0xFFFEF3C7)], ), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFFBBF24)), boxShadow: const [ BoxShadow( color: Color(0x22F59E0B), blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(LucideIcons.timer, size: 18, color: Color(0xFFD97706)), const SizedBox(width: 12), const Expanded( child: Text( 'Content is valid for 24 hours. Please save it before ' 'it expires.', style: TextStyle( color: Color(0xFF78350F), fontSize: 12, fontWeight: FontWeight.w600, height: 1.4, fontFamily: 'Inter', ), ), ), ], ), ); } } /// Gallery screen - matches Pencil hpwBg class GalleryScreen extends StatefulWidget { const GalleryScreen({super.key, required this.isActive}); final bool isActive; @override State createState() => _GalleryScreenState(); } class _GalleryScreenState extends State { List _tasks = []; bool _loading = true; bool _loadingMore = false; String? _error; int _currentPage = 1; bool _hasNext = false; bool _hasLoadedOnce = false; final ScrollController _scrollController = ScrollController(); static const int _pageSize = 20; /// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」 static const double _videoVisibilityThreshold = 0.08; /// 可见格子按曝光比例排序,同时最多自动播放 [deviceGridMaxConcurrentVideos] 路;滑出后 [VideoCard] 释放解码器 Set _visibleVideoIndices = {}; final Set _userPausedVideoIndices = {}; final Map _cardVisibleFraction = {}; Timer? _visibilityDebounce; Timer? _remainingLabelTicker; /// taskId(tree) -> 本地上传封面路径(接口无封面时使用) Map _localCoverPaths = {}; /// [IndexedStack] 非当前 Tab 时子树可见性常为 0;首次切到本页时若仅打一次 [notifyNow], /// 可能在 Grid 尚未挂载(仍在 loading)或首帧未稳定时执行,导致预览不自动播放。 /// 多帧 + 短延迟与数据就绪后再通知一次,与首页 [HomeScreen._scheduleVisibilityRefresh] 思路一致。 void _scheduleGalleryVisibilityRefresh() { if (!mounted || !widget.isActive) return; void notify() { if (!mounted || !widget.isActive) return; VisibilityDetectorController.instance.notifyNow(); } WidgetsBinding.instance.addPostFrameCallback((_) { notify(); WidgetsBinding.instance.addPostFrameCallback((_) { notify(); WidgetsBinding.instance.addPostFrameCallback((_) { notify(); }); }); }); Future.delayed(const Duration(milliseconds: 80), notify); Future.delayed(const Duration(milliseconds: 220), notify); } @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _remainingLabelTicker = Timer.periodic(const Duration(minutes: 1), (_) { if (mounted) setState(() {}); }); if (widget.isActive) _loadTasks(refresh: true); } @override void didUpdateWidget(covariant GalleryScreen oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isActive && !oldWidget.isActive) { _scheduleGalleryVisibilityRefresh(); if (!_hasLoadedOnce) { _loadTasks(refresh: true); } } } @override void dispose() { _remainingLabelTicker?.cancel(); _visibilityDebounce?.cancel(); _scrollController.dispose(); super.dispose(); } void _onScroll() { if (_loadingMore || !_hasNext || _tasks.isEmpty) return; if (!_scrollController.hasClients) return; final pos = _scrollController.position; if (pos.pixels >= pos.maxScrollExtent - 200) { _loadTasks(refresh: false); } } void _onGridCardVisibilityChanged(int index, VisibilityInfo info) { if (!mounted) return; if (info.visibleFraction >= _videoVisibilityThreshold) { _cardVisibleFraction[index] = info.visibleFraction; } else { _cardVisibleFraction.remove(index); } // 防抖:滚动时可见性回调极频繁,立刻 setState 会让 VideoCard / FutureBuilder 反复重建导致闪屏 _visibilityDebounce?.cancel(); _visibilityDebounce = Timer(const Duration(milliseconds: 120), () { if (mounted) _reconcileVisibleVideoIndicesFromDetector(); }); } void _reconcileVisibleVideoIndicesFromDetector() { final items = _gridItems; final scored = >[]; 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) { scored.add(MapEntry(i, e.value)); } } scored.sort((a, b) => b.value.compareTo(a.value)); final next = scored .take(deviceGridMaxConcurrentVideos) .map((e) => e.key) .toSet(); _userPausedVideoIndices.removeWhere((i) => !next.contains(i)); if (!_setsEqual(next, _visibleVideoIndices)) { setState(() => _visibleVideoIndices = next); } } bool _setsEqual(Set a, Set b) { if (a.length != b.length) return false; for (final e in a) { if (!b.contains(e)) return false; } return true; } static const String _defaultGalleryCoverAsset = 'assets/images/logo.png'; /// 网络封面优先;无则本地上传图(按 tree);再无则默认占位图 ({String imageUrl, Widget? cover}) _galleryCardCover(GalleryMediaItem media) { final net = media.imageUrl?.trim() ?? ''; if (net.isNotEmpty) { return (imageUrl: net, cover: null); } final tid = media.taskId; if (tid != null && tid > 0) { final path = _localCoverPaths[tid]; if (path != null && path.isNotEmpty && File(path).existsSync()) { return ( imageUrl: '', cover: Image.file(File(path), fit: BoxFit.cover), ); } } return ( imageUrl: '', cover: Image.asset( _defaultGalleryCoverAsset, fit: BoxFit.cover, ), ); } Future _refreshLocalCoverPaths() async { if (!mounted) return; final ids = _tasks.map((t) => t.taskId).where((id) => id > 0).toSet(); final paths = await GalleryUploadCoverStore.existingPathsForTaskIds(ids); if (!mounted) return; setState(() => _localCoverPaths = paths); } void _showGalleryMessage(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } /// pending / 生成中 → 进度页;finished + 远程 URL → 结果下载页;其它状态 SnackBar 提示 void _onGalleryMediaTap(GalleryMediaItem media) { final raw = media.listingRaw; final disp = media.listingDisplay; if (galleryListingIsInProgress(raw, disp)) { final tid = media.taskId; if (tid == null || tid <= 0) { _showGalleryMessage('Cannot open progress: missing task id.'); return; } final path = _localCoverPaths[tid]; final pathOk = path != null && path.isNotEmpty && File(path).existsSync(); Navigator.of(context).pushNamed( '/progress', arguments: { 'taskId': tid, if (pathOk) 'imagePath': path, }, ); return; } if (galleryListingIsFinishedSuccess(raw, disp)) { if (!galleryMediaHasRemoteUrl(media)) { _showGalleryMessage( 'Media is not ready yet. Please wait or pull to refresh.', ); return; } Navigator.of(context).pushNamed('/result', arguments: media); return; } _showGalleryMessage(galleryListingBlockedHint(raw, disp)); } Future _loadTasks({bool refresh = true}) async { if (refresh) { setState(() { _loading = true; _error = null; _currentPage = 1; }); } else { if (_loadingMore || !_hasNext) return; setState(() => _loadingMore = true); } try { await AuthService.loginComplete; final page = refresh ? 1 : _currentPage; final res = await ImageApi.getMyTasks( sentinel: ApiConfig.appId, trophy: page.toString(), heatmap: _pageSize.toString(), ); if (!mounted) return; if (res.isSuccess && res.data != null) { final data = res.data as Map?; final intensify = data?['intensify'] as List? ?? []; final list = intensify .whereType>() .map((e) => GalleryTaskItem.fromJson(e)) .toList(); final hasNext = data?['manifest'] as bool? ?? false; setState(() { if (refresh) { _tasks = list; _visibleVideoIndices = {}; _userPausedVideoIndices.clear(); _cardVisibleFraction.clear(); _localCoverPaths = {}; } else { _tasks = [..._tasks, ...list]; } _currentPage = page + 1; _hasNext = hasNext; _loading = false; _loadingMore = false; _hasLoadedOnce = true; }); await _refreshLocalCoverPaths(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _scheduleGalleryVisibilityRefresh(); }); } else { setState(() { if (refresh) _tasks = []; _loading = false; _loadingMore = false; _error = res.msg.isNotEmpty ? res.msg : 'Failed to load'; }); } } catch (e) { if (mounted) { setState(() { if (refresh) _tasks = []; _loading = false; _loadingMore = false; _error = e.toString(); }); } } } List get _gridItems { final items = []; for (final task in _tasks) { items.addAll(task.mediaItems); } return items; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, appBar: const PreferredSize( preferredSize: Size.fromHeight(56), child: TopNavBar( title: 'Gallery', ), ), body: _loading ? const Center(child: CircularProgressIndicator()) : _error != null ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( _error!, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: AppSpacing.lg), TextButton( onPressed: _loadTasks, child: const Text('Retry'), ), ], ), ) : LayoutBuilder( builder: (context, constraints) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 390), child: RefreshIndicator( onRefresh: () => _loadTasks(refresh: true), child: _gridItems.isEmpty && !_loading ? CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, cacheExtent: 800, slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.screenPadding, AppSpacing.xl, AppSpacing.screenPadding, 16, ), child: const _GalleryRetentionBanner(), ), ), SliverFillRemaining( hasScrollBody: false, child: SizedBox( height: max(0.0, constraints.maxHeight - 220), child: const Center( child: Text( 'No images yet', style: TextStyle( color: AppColors.textSecondary, ), ), ), ), ), ], ) : CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, cacheExtent: 800, slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.screenPadding, AppSpacing.xl, AppSpacing.screenPadding, 16, ), child: const _GalleryRetentionBanner(), ), ), SliverPadding( padding: EdgeInsets.fromLTRB( AppSpacing.screenPadding, 0, AppSpacing.screenPadding, AppSpacing.screenPaddingLarge + (_loadingMore ? 48.0 : 0), ), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 165 / 248, mainAxisSpacing: AppSpacing.xl, crossAxisSpacing: AppSpacing.xl, ), delegate: SliverChildBuilderDelegate( (context, index) { if (index >= _gridItems.length) { return const Center( child: Padding( padding: EdgeInsets.all(16), child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, ), ), ), ); } final media = _gridItems[index]; final coverSpecs = _galleryCardCover(media); final videoUrl = media.videoUrl; final hasVideo = !deviceGridStaticPreviewOnly && videoUrl != null && videoUrl.isNotEmpty; final detectorKey = 'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}'; final created = _galleryCardCreatedLine(media); final remaining = _galleryCardRemainingLine( media); return VisibilityDetector( key: ValueKey(detectorKey), onVisibilityChanged: hasVideo ? (info) => _onGridCardVisibilityChanged( index, info) : (_) {}, child: RepaintBoundary( child: VideoCard( key: ValueKey(detectorKey), imageUrl: coverSpecs.imageUrl, cover: coverSpecs.cover, videoUrl: hasVideo ? videoUrl : null, credits: '50', showCreditsBadge: false, showBottomGenerateButton: false, cardMetaCreatedText: created, cardMetaRemainingText: remaining, cardMetaRemainingColor: _galleryCardRemainingColor( media), topRightStatusText: media.listingDisplay, isActive: widget.isActive && hasVideo && _visibleVideoIndices .contains(index) && !_userPausedVideoIndices .contains(index), onPlayRequested: () => setState(() => _userPausedVideoIndices .remove(index)), onStopRequested: () => setState(() => _userPausedVideoIndices .add(index)), onGenerateSimilar: () => _onGalleryMediaTap(media), ), ), ); }, childCount: _gridItems.length + (_loadingMore ? 1 : 0), ), ), ), ], ), ), ), ); }, ), ); } }