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

492 lines
19 KiB
Dart
Raw Permalink 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 '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;
/// 可见格子按曝光比例排序,同时最多 [_maxConcurrentHomeVideos] 路;滑出后 [VideoCard] 释放解码器
static const int _maxConcurrentHomeVideos = 4;
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.itemstrue 且选中固定分类时用 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),
),
);
},
),
),
);
},
),
),
],
),
);
}
}