import 'dart:async'; import 'dart:io'; import 'package:flutter/material.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 '../../shared/widgets/top_nav_bar.dart'; import '../home/widgets/video_card.dart'; import 'gallery_upload_cover_store.dart'; import 'models/gallery_task_item.dart'; /// 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; /// 可见格子按曝光比例排序,同时最多自动播放 [_maxConcurrentGalleryVideos] 路;滑出后 [VideoCard] 释放解码器 static const int _maxConcurrentGalleryVideos = 4; Set _visibleVideoIndices = {}; final Set _userPausedVideoIndices = {}; final Map _cardVisibleFraction = {}; Timer? _visibilityDebounce; /// 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); 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() { _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(_maxConcurrentGalleryVideos) .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); } 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: 'My 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 ? SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: constraints.maxHeight - 100, child: const Center( child: Text( 'No images yet', style: TextStyle( color: AppColors.textSecondary, ), ), ), ), ) : GridView.builder( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, cacheExtent: 800, padding: EdgeInsets.fromLTRB( AppSpacing.screenPadding, AppSpacing.xl, AppSpacing.screenPadding, AppSpacing.screenPaddingLarge + (_loadingMore ? 48.0 : 0), ), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 165 / 248, mainAxisSpacing: AppSpacing.xl, crossAxisSpacing: AppSpacing.xl, ), itemCount: _gridItems.length + (_loadingMore ? 1 : 0), itemBuilder: (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 = videoUrl != null && videoUrl.isNotEmpty; final detectorKey = 'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}'; void openResult() { Navigator.of(context).pushNamed( '/result', arguments: 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, isActive: widget.isActive && hasVideo && _visibleVideoIndices .contains(index) && !_userPausedVideoIndices .contains(index), onPlayRequested: () => setState(() => _userPausedVideoIndices .remove(index)), onStopRequested: () => setState(() => _userPausedVideoIndices .add(index)), onGenerateSimilar: openResult, ), ), ); }, ), ), ), ); }, ), ); } }