492 lines
19 KiB
Dart
492 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:visibility_detector/visibility_detector.dart';
|
||
|
||
import '../../core/api/services/image_api.dart';
|
||
import '../../core/auth/auth_service.dart';
|
||
import '../../core/user/account_refresh.dart';
|
||
import '../../core/user/user_state.dart';
|
||
import '../../core/theme/app_spacing.dart';
|
||
import '../../shared/widgets/top_nav_bar.dart';
|
||
import 'models/category_item.dart';
|
||
import 'models/ext_config_item.dart';
|
||
import 'models/task_item.dart';
|
||
import 'widgets/home_tab_row.dart';
|
||
import 'home_playback_resume.dart';
|
||
import 'widgets/video_card.dart';
|
||
|
||
/// 固定「pets」分类 id,用于展示 extConfig.items
|
||
const int kExtCategoryId = -1;
|
||
|
||
/// AI Video App home screen - tab 来自分类接口,Grid 来自任务列表或 extConfig.items
|
||
class HomeScreen extends StatefulWidget {
|
||
const HomeScreen({super.key, this.isActive = true});
|
||
|
||
final bool isActive;
|
||
|
||
@override
|
||
State<HomeScreen> createState() => _HomeScreenState();
|
||
}
|
||
|
||
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||
List<CategoryItem> _categories = [];
|
||
CategoryItem? _selectedCategory;
|
||
List<TaskItem> _tasks = [];
|
||
bool _categoriesLoading = true;
|
||
bool _tasksLoading = false;
|
||
/// 当前在屏幕上有足够可见比例、且带有预览视频的格子(由 [VisibilityDetector] 实测,允许多个)
|
||
Set<int> _visibleVideoIndices = {};
|
||
/// 用户在本轮可视周期内点过「停止」的索引;滑出视口后清除,再次进入可自动播放
|
||
final Set<int> _userPausedVideoIndices = {};
|
||
final ScrollController _scrollController = ScrollController();
|
||
/// [index] -> 最近一次 visibility 回调的 [VisibilityInfo.visibleFraction]
|
||
final Map<int, double> _cardVisibleFraction = {};
|
||
bool _visibilityReconcileScheduled = false;
|
||
/// 是否曾有过「可见比例 ≥ 阈值」的探测器回调(用于区分冷启动无回调 vs 已全部滑出视口)
|
||
bool _visibilityHadMeaningfulReport = false;
|
||
|
||
/// 统一入口:递增 nonce → [VideoCard] 恢复播放 + [_onHomePlaybackResumeSignal] 内多帧刷新可见性。
|
||
void _requestPlaybackPipeline() {
|
||
if (!mounted || !widget.isActive) return;
|
||
homePlaybackResumeNonce.value++;
|
||
}
|
||
|
||
/// [homePlaybackResumeNonce] 监听里调用:多帧 + 短延迟 [notifyNow],避免单次过早。
|
||
void _scheduleVisibilityRefresh() {
|
||
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);
|
||
// 冷启动首屏 Grid 布局/Texture 较晚就绪时,前两档仍可能拿不到可见性
|
||
Future<void>.delayed(const Duration(milliseconds: 450), notify);
|
||
Future<void>.delayed(const Duration(milliseconds: 720), notify);
|
||
Future<void>.delayed(const Duration(milliseconds: 950), () {
|
||
if (!mounted || !widget.isActive) return;
|
||
VisibilityDetectorController.instance.notifyNow();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted || !widget.isActive) return;
|
||
_seedFirstScreenVideoIndicesIfStillEmpty();
|
||
});
|
||
});
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
UserState.needShowVideoMenu.addListener(_onExtConfigChanged);
|
||
UserState.extConfigItems.addListener(_onExtConfigChanged);
|
||
AuthService.isLoginComplete.addListener(_onExtConfigChanged);
|
||
UserState.homeReloadNonce.addListener(_onHomeReloadNonce);
|
||
homePlaybackResumeNonce.addListener(_onHomePlaybackResumeSignal);
|
||
_loadCategories();
|
||
if (widget.isActive) refreshAccount();
|
||
}
|
||
|
||
void _seedFirstScreenVideoIndicesIfStillEmpty() {
|
||
if (!mounted || !widget.isActive) return;
|
||
if (_visibleVideoIndices.isNotEmpty) return;
|
||
if (_visibilityHadMeaningfulReport) return;
|
||
final tasks = _displayTasks;
|
||
if (tasks.isEmpty) return;
|
||
final seed = <int>{};
|
||
for (var i = 0; i < tasks.length && seed.length < _maxConcurrentHomeVideos; i++) {
|
||
final u = tasks[i].previewVideoUrl;
|
||
if (u != null && u.isNotEmpty) seed.add(i);
|
||
}
|
||
if (seed.isEmpty) return;
|
||
setState(() => _visibleVideoIndices = seed);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
homePlaybackResumeNonce.removeListener(_onHomePlaybackResumeSignal);
|
||
UserState.needShowVideoMenu.removeListener(_onExtConfigChanged);
|
||
UserState.extConfigItems.removeListener(_onExtConfigChanged);
|
||
AuthService.isLoginComplete.removeListener(_onExtConfigChanged);
|
||
UserState.homeReloadNonce.removeListener(_onHomeReloadNonce);
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onHomePlaybackResumeSignal() {
|
||
if (!mounted || !widget.isActive) return;
|
||
_scheduleVisibilityRefresh();
|
||
}
|
||
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
super.didChangeAppLifecycleState(state);
|
||
if (state == AppLifecycleState.resumed && mounted && widget.isActive) {
|
||
_requestPlaybackPipeline();
|
||
}
|
||
}
|
||
|
||
void _onExtConfigChanged() {
|
||
if (mounted) setState(() {});
|
||
// 不用 Timer 防抖:连续 listener 会互相 cancel,首装可能永远不触发 notify
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted && widget.isActive) _requestPlaybackPipeline();
|
||
});
|
||
}
|
||
|
||
void _onHomeReloadNonce() {
|
||
if (!mounted) return;
|
||
_loadCategories();
|
||
}
|
||
|
||
/// 与 Gallery 一致:边缘格子可见比例偏低时也应参与自动播
|
||
static const double _videoVisibilityThreshold = 0.08;
|
||
/// 凡达到阈值的格子均可播;滑出后 [VideoCard] 会释放解码器,此处仅防极端情况
|
||
static const int _maxConcurrentHomeVideos = 16;
|
||
|
||
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
|
||
if (!mounted || !widget.isActive) return;
|
||
if (info.visibleFraction >= _videoVisibilityThreshold) {
|
||
_cardVisibleFraction[index] = info.visibleFraction;
|
||
_visibilityHadMeaningfulReport = true;
|
||
} else {
|
||
_cardVisibleFraction.remove(index);
|
||
}
|
||
if (_visibilityReconcileScheduled) return;
|
||
_visibilityReconcileScheduled = true;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_visibilityReconcileScheduled = false;
|
||
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
|
||
});
|
||
}
|
||
|
||
void _reconcileVisibleVideoIndicesFromDetector() {
|
||
if (!mounted || !widget.isActive) return;
|
||
final tasks = _displayTasks;
|
||
final scored = <MapEntry<int, double>>[];
|
||
for (final e in _cardVisibleFraction.entries) {
|
||
final i = e.key;
|
||
if (i < 0 || i >= tasks.length) continue;
|
||
if (e.value < _videoVisibilityThreshold) continue;
|
||
final url = tasks[i].previewVideoUrl;
|
||
if (url != null && url.isNotEmpty) {
|
||
scored.add(MapEntry(i, e.value));
|
||
}
|
||
}
|
||
if (scored.isEmpty) {
|
||
// 冷启动:探测器尚未给出 ≥ 阈值的回调时,不要用空集覆盖(否则会清掉兜底或一直不播)
|
||
if (!_visibilityHadMeaningfulReport) return;
|
||
_userPausedVideoIndices.clear();
|
||
if (!_setsEqual(_visibleVideoIndices, {})) {
|
||
setState(() => _visibleVideoIndices = {});
|
||
}
|
||
return;
|
||
}
|
||
|
||
scored.sort((a, b) => b.value.compareTo(a.value));
|
||
final next = scored
|
||
.take(_maxConcurrentHomeVideos)
|
||
.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;
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (widget.isActive && !oldWidget.isActive) {
|
||
refreshAccount();
|
||
_requestPlaybackPipeline();
|
||
}
|
||
}
|
||
|
||
/// 仅 need_wait === true 时展示 Video 分类栏;其他(false/null/未解析)只显示图片列表
|
||
bool get _showVideoMenu =>
|
||
UserState.needShowVideoMenu.value == true;
|
||
|
||
List<ExtConfigItem> get _parsedExtItems {
|
||
final raw = UserState.extConfigItems.value;
|
||
if (raw == null || raw.isEmpty) return [];
|
||
return raw
|
||
.map((e) => e is Map<String, dynamic>
|
||
? ExtConfigItem.fromJson(e)
|
||
: ExtConfigItem.fromJson(Map<String, dynamic>.from(e as Map)))
|
||
.toList();
|
||
}
|
||
|
||
/// 是否处于首次加载中(分类/任务/extConfig 尚未就绪)
|
||
/// 登录未完成时不显示页面加载指示器,由登录遮罩负责
|
||
bool get _isListLoading {
|
||
if (!AuthService.isLoginComplete.value) return false;
|
||
if (_showVideoMenu) {
|
||
if (_categoriesLoading) return true;
|
||
if (_selectedCategory?.id == kExtCategoryId) {
|
||
return UserState.extConfigItems.value == null;
|
||
}
|
||
return _tasksLoading;
|
||
}
|
||
return UserState.extConfigItems.value == null;
|
||
}
|
||
|
||
/// 分类栏加载中时是否显示加载指示器(登录未完成时不显示)
|
||
bool get _showCategoriesLoading =>
|
||
AuthService.isLoginComplete.value && _categoriesLoading;
|
||
|
||
/// 当前列表:need_wait false 时用 extConfig.items;true 且选中固定分类时用 extConfig.items;否则用 _tasks
|
||
List<TaskItem> get _displayTasks {
|
||
if (!_showVideoMenu) {
|
||
return _extItemsToTaskItems(_parsedExtItems);
|
||
}
|
||
if (_selectedCategory?.id == kExtCategoryId) {
|
||
return _extItemsToTaskItems(_parsedExtItems);
|
||
}
|
||
return _tasks;
|
||
}
|
||
|
||
static List<TaskItem> _extItemsToTaskItems(List<ExtConfigItem> items) {
|
||
return items
|
||
.map((e) => TaskItem(
|
||
templateName: e.title,
|
||
title: e.title,
|
||
previewImageUrl: e.image,
|
||
// 尝试从 detail 字段中提取视频 URL(如果有)
|
||
previewVideoUrl: null, // 暂时保持为 null,需要根据实际数据结构调整
|
||
taskType: e.title,
|
||
ext: e.detail,
|
||
credits480p: e.cost,
|
||
))
|
||
.toList();
|
||
}
|
||
|
||
Future<void> _loadCategories() async {
|
||
print('HomeScreen: _loadCategories called');
|
||
setState(() => _categoriesLoading = true);
|
||
await AuthService.loginComplete;
|
||
if (!mounted) return;
|
||
final res = await ImageApi.getCategoryList();
|
||
if (mounted) {
|
||
if (res.isSuccess && res.data is List) {
|
||
final list = (res.data as List)
|
||
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
||
.toList();
|
||
print('HomeScreen: Categories loaded: ${list.length} items');
|
||
if (UserState.needShowVideoMenu.value == true) {
|
||
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
|
||
print('HomeScreen: Added pets category');
|
||
}
|
||
setState(() {
|
||
_categories = list;
|
||
_selectedCategory = list.isNotEmpty ? list.first : null;
|
||
print('HomeScreen: Selected category: ${_selectedCategory?.name}');
|
||
if (_selectedCategory != null) {
|
||
if (_selectedCategory!.id == kExtCategoryId) {
|
||
_tasks = [];
|
||
_tasksLoading = false;
|
||
print('HomeScreen: Selected pets category, tasks cleared');
|
||
} else {
|
||
print('HomeScreen: Loading tasks for category: ${_selectedCategory!.id}');
|
||
_loadTasks(_selectedCategory!.id);
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
setState(() => _categories = []);
|
||
print('HomeScreen: Failed to load categories');
|
||
}
|
||
setState(() => _categoriesLoading = false);
|
||
// 默认选中 pets 等不会走 [_loadTasks] 的路径,也要触发一次可见性(否则预览不自动播)
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted && widget.isActive) _requestPlaybackPipeline();
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _loadTasks(int categoryId) async {
|
||
setState(() => _tasksLoading = true);
|
||
final res = await ImageApi.getImg2VideoTasks(insignia: categoryId);
|
||
if (mounted) {
|
||
if (res.isSuccess && res.data is List) {
|
||
final list = (res.data as List)
|
||
.map((e) => TaskItem.fromJson(e as Map<String, dynamic>))
|
||
.toList();
|
||
setState(() {
|
||
_tasks = list;
|
||
_userPausedVideoIndices.clear();
|
||
_visibleVideoIndices = {};
|
||
_cardVisibleFraction.clear();
|
||
_visibilityHadMeaningfulReport = false;
|
||
});
|
||
} else {
|
||
setState(() {
|
||
_tasks = [];
|
||
_userPausedVideoIndices.clear();
|
||
_visibleVideoIndices = {};
|
||
_cardVisibleFraction.clear();
|
||
_visibilityHadMeaningfulReport = false;
|
||
});
|
||
}
|
||
setState(() => _tasksLoading = false);
|
||
// 列表替换后须强制可见性重算,否则探测器有时不回调,预览一直不播
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted && widget.isActive) _requestPlaybackPipeline();
|
||
});
|
||
}
|
||
}
|
||
|
||
void _onTabChanged(CategoryItem c) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.jumpTo(0);
|
||
}
|
||
setState(() => _selectedCategory = c);
|
||
if (c.id == kExtCategoryId) {
|
||
setState(() {
|
||
_tasks = [];
|
||
_tasksLoading = false;
|
||
_userPausedVideoIndices.clear();
|
||
_visibleVideoIndices = {};
|
||
_cardVisibleFraction.clear();
|
||
_visibilityHadMeaningfulReport = false;
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted && widget.isActive) _requestPlaybackPipeline();
|
||
});
|
||
} else {
|
||
_loadTasks(c.id);
|
||
}
|
||
}
|
||
|
||
static const _placeholderImage =
|
||
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
|
||
|
||
/// 与 [GenerateVideoScreen] 默认 480P 消耗一致;不足则去充值,够才进生图页
|
||
void _openGeneratePage(TaskItem task) {
|
||
final requiredCredits = task.credits480p ?? 50;
|
||
final balance = UserState.credits.value ?? 0;
|
||
if (balance < requiredCredits) {
|
||
Navigator.of(context).pushNamed('/recharge');
|
||
return;
|
||
}
|
||
Navigator.of(context).pushNamed('/generate', arguments: task);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFFAFAFA),
|
||
appBar: PreferredSize(
|
||
preferredSize: const Size.fromHeight(56),
|
||
child: TopNavBar(
|
||
title: 'PetsHero AI',
|
||
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
||
),
|
||
),
|
||
body: Column(
|
||
children: [
|
||
// 仅 need_wait == true 时展示顶部分类栏(Pencil: tabRow bK6o6)
|
||
if (_showVideoMenu)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.screenPadding,
|
||
vertical: AppSpacing.xs,
|
||
),
|
||
child: _showCategoriesLoading
|
||
? const SizedBox(
|
||
height: 40,
|
||
child: Center(child: CircularProgressIndicator()))
|
||
: HomeTabRow(
|
||
categories: _categories,
|
||
selectedId: _selectedCategory?.id ?? -1,
|
||
onTabChanged: _onTabChanged,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: _isListLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final tasks = _displayTasks;
|
||
return Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 390),
|
||
child: GridView.builder(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpacing.screenPadding,
|
||
AppSpacing.xl,
|
||
AppSpacing.screenPadding,
|
||
AppSpacing.screenPaddingLarge,
|
||
),
|
||
controller: _scrollController,
|
||
cacheExtent: 800,
|
||
gridDelegate:
|
||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 2,
|
||
childAspectRatio: 165 / 248,
|
||
mainAxisSpacing: AppSpacing.xl,
|
||
crossAxisSpacing: AppSpacing.xl,
|
||
),
|
||
itemCount: tasks.length,
|
||
itemBuilder: (context, index) {
|
||
final task = tasks[index];
|
||
final credits = task.credits480p != null
|
||
? task.credits480p.toString()
|
||
: '50';
|
||
final detectorKey =
|
||
'home_card_${index}_${task.previewVideoUrl ?? ''}_${task.title}';
|
||
return VisibilityDetector(
|
||
key: ValueKey(detectorKey),
|
||
onVisibilityChanged: (info) =>
|
||
_onGridCardVisibilityChanged(index, info),
|
||
child: VideoCard(
|
||
key: ValueKey(detectorKey),
|
||
imageUrl: task.previewImageUrl ??
|
||
_placeholderImage,
|
||
videoUrl: task.previewVideoUrl,
|
||
credits: credits,
|
||
playbackResumeListenable:
|
||
homePlaybackResumeNonce,
|
||
isActive: widget.isActive &&
|
||
_visibleVideoIndices.contains(index) &&
|
||
!_userPausedVideoIndices
|
||
.contains(index),
|
||
onPlayRequested: () => setState(() =>
|
||
_userPausedVideoIndices.remove(index)),
|
||
onStopRequested: () => setState(() =>
|
||
_userPausedVideoIndices.add(index)),
|
||
onGenerateSimilar: () =>
|
||
_openGeneratePage(task),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|