604 lines
24 KiB
Dart
604 lines
24 KiB
Dart
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<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;
|
||
/// 可见格子按曝光比例排序,同时最多自动播放 [deviceGridMaxConcurrentVideos] 路;滑出后 [VideoCard] 释放解码器
|
||
Set<int> _visibleVideoIndices = {};
|
||
final Set<int> _userPausedVideoIndices = {};
|
||
final Map<int, double> _cardVisibleFraction = {};
|
||
Timer? _visibilityDebounce;
|
||
Timer? _remainingLabelTicker;
|
||
/// 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);
|
||
_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 = <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(deviceGridMaxConcurrentVideos)
|
||
.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);
|
||
}
|
||
|
||
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<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: '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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|