petsHero-AI/lib/features/gallery/gallery_screen.dart
2026-03-31 09:42:49 +08:00

411 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<GalleryScreen> createState() => _GalleryScreenState();
}
class _GalleryScreenState extends State<GalleryScreen> {
List<GalleryTaskItem> _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<int> _visibleVideoIndices = {};
final Set<int> _userPausedVideoIndices = {};
final Map<int, double> _cardVisibleFraction = {};
Timer? _visibilityDebounce;
/// taskId(tree) -> 本地上传封面路径(接口无封面时使用)
Map<int, String> _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<void>.delayed(const Duration(milliseconds: 80), notify);
Future<void>.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 = <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) {
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<int> a, Set<int> 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<void> _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<void> _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<String, dynamic>?;
final intensify = data?['intensify'] as List<dynamic>? ?? [];
final list = intensify
.whereType<Map<String, dynamic>>()
.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<GalleryMediaItem> get _gridItems {
final items = <GalleryMediaItem>[];
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,
),
),
);
},
),
),
),
);
},
),
);
}
}