399 lines
15 KiB
Dart
399 lines
15 KiB
Dart
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.15;
|
||
Set<int> _visibleVideoIndices = {};
|
||
final Set<int> _userPausedVideoIndices = {};
|
||
final Map<int, double> _cardVisibleFraction = {};
|
||
bool _visibilityReconcileScheduled = false;
|
||
|
||
/// [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() {
|
||
_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);
|
||
}
|
||
if (_visibilityReconcileScheduled) return;
|
||
_visibilityReconcileScheduled = true;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_visibilityReconcileScheduled = false;
|
||
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
|
||
});
|
||
}
|
||
|
||
void _reconcileVisibleVideoIndicesFromDetector() {
|
||
final items = _gridItems;
|
||
final next = <int>{};
|
||
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);
|
||
}
|
||
}
|
||
_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,
|
||
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: 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 StatelessWidget {
|
||
const _VideoThumbnailCover({required this.videoUrl});
|
||
|
||
final String videoUrl;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FutureBuilder<Uint8List?>(
|
||
future: VideoThumbnailCache.instance.getThumbnail(videoUrl),
|
||
builder: (context, snapshot) {
|
||
if (snapshot.hasData && snapshot.data != null) {
|
||
return Image.memory(
|
||
snapshot.data!,
|
||
fit: BoxFit.cover,
|
||
);
|
||
}
|
||
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(),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|