petsHero-AI/lib/features/gallery/gallery_screen.dart
2026-03-29 23:53:24 +08:00

435 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:typed_data';
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 'models/gallery_task_item.dart';
import 'video_thumbnail_cache.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;
/// 一屏约 2×(34) 个格子;不再强行只播 2 路(会总挑中间行)。滑出后 [VideoCard] 会释放解码器,此处仅防极端滚动时实例过多
static const int _maxConcurrentGalleryVideos = 16;
Set<int> _visibleVideoIndices = {};
final Set<int> _userPausedVideoIndices = {};
final Map<int, double> _cardVisibleFraction = {};
Timer? _visibilityDebounce;
/// [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;
}
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();
} else {
_tasks = [..._tasks, ...list];
}
_currentPage = page + 1;
_hasNext = hasNext;
_loading = false;
_loadingMore = false;
_hasLoadedOnce = true;
});
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 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: media.imageUrl ??
videoUrl ??
'',
cover: hasVideo
? _VideoThumbnailCover(
videoUrl: media.videoUrl!,
)
: null,
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,
),
),
);
},
),
),
),
);
},
),
);
}
}
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: _thumbFuture,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
return Container(
color: AppColors.surfaceAlt,
child: snapshot.connectionState == ConnectionState.waiting
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const SizedBox.shrink(),
);
},
);
}
}