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 createState() => _HomeScreenState(); } class _HomeScreenState extends State with WidgetsBindingObserver { List _categories = []; CategoryItem? _selectedCategory; List _tasks = []; bool _categoriesLoading = true; bool _tasksLoading = false; /// 当前在屏幕上有足够可见比例、且带有预览视频的格子(由 [VisibilityDetector] 实测,允许多个) Set _visibleVideoIndices = {}; /// 用户在本轮可视周期内点过「停止」的索引;滑出视口后清除,再次进入可自动播放 final Set _userPausedVideoIndices = {}; final ScrollController _scrollController = ScrollController(); /// [index] -> 最近一次 visibility 回调的 [VisibilityInfo.visibleFraction] final Map _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.delayed(const Duration(milliseconds: 80), notify); Future.delayed(const Duration(milliseconds: 220), notify); // 冷启动首屏 Grid 布局/Texture 较晚就绪时,前两档仍可能拿不到可见性 Future.delayed(const Duration(milliseconds: 450), notify); Future.delayed(const Duration(milliseconds: 720), notify); Future.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 = {}; 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 = >[]; 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 a, Set 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 get _parsedExtItems { final raw = UserState.extConfigItems.value; if (raw == null || raw.isEmpty) return []; return raw .map((e) => e is Map ? ExtConfigItem.fromJson(e) : ExtConfigItem.fromJson(Map.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 get _displayTasks { if (!_showVideoMenu) { return _extItemsToTaskItems(_parsedExtItems); } if (_selectedCategory?.id == kExtCategoryId) { return _extItemsToTaskItems(_parsedExtItems); } return _tasks; } static List _extItemsToTaskItems(List 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 _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)) .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 _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)) .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), ), ); }, ), ), ); }, ), ), ], ), ); } }