import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/log/action_logger.dart'; import '../../core/storage/feed_progress_store.dart'; import '../../core/theme/app_theme.dart'; import '../../models/video_item.dart'; import '../../providers/auth_provider.dart'; import '../../providers/feed_provider.dart'; import '../../router.dart'; import 'widgets/comment_sheet.dart'; import 'widgets/feed_overlays.dart'; import 'widgets/feed_video_page.dart'; /// 首页 Feed:全屏竖向 PageView 沉浸式播放(《01-开发文档》5.2.1) /// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索入口 /// - 底部:游客/非会员免费剩余次数提示条 /// - 滑出暂停、滑入自动播放并上报观看计费 class FeedScreen extends ConsumerStatefulWidget { const FeedScreen({super.key}); @override ConsumerState createState() => _FeedScreenState(); } class _FeedScreenState extends ConsumerState with WidgetsBindingObserver, RouteAware { FeedStrategy _strategy = FeedStrategy.recommend; final PageController _pageController = PageController(); StreamSubscription? _eventSub; /// 已完成首屏位置同步的策略(冷启动续播跳页只需一次) final Set _initialSyncDone = {}; /// 定时兜底保存播放进度(被杀进程时也能续播) Timer? _progressTimer; /// App 是否在前台(退后台后不写进度) bool _appActive = true; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); // 冷启动恢复上次所在的 Tab(口碑/热播等),再按该策略的进度续播 final lastName = FeedProgressStore.I.readLastStrategy(); _strategy = FeedStrategy.values.firstWhere( (s) => s.name == lastName && s != FeedStrategy.search, orElse: () => FeedStrategy.recommend, ); // 登记当前策略,HomeShell 切回首页时只恢复这个策略的播放器池 ref.read(activeFeedStrategyProvider.notifier).state = _strategy; // 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动) _eventSub = ref .read(feedProvider(_strategy).notifier) .events .listen(_handleEvent); // 从登录页等返回时恢复播放:provider 全局常驻保留了列表, // 但槽位已在 dispose 时 stopAll,需重新绑定(冷启动列表为空时为 no-op) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) ref.read(feedProvider(_strategy).notifier).rebindCurrent(); }); _progressTimer = Timer.periodic(const Duration(seconds: 5), (_) { if (!mounted || !_appActive) return; if (!ref.read(feedTabVisibleProvider)) return; unawaited(ref.read(feedProvider(_strategy).notifier).persistProgress()); }); } @override void didChangeDependencies() { super.didChangeDependencies(); // 注册路由感知:当上层路由 pop 露出 FeedScreen 时(didPopNext), // 若首页 Tab 仍选中,恢复播放——这是处理「搜索页/分类页返回后无声」的唯一可靠入口 final route = ModalRoute.of(context); if (route is PageRoute) { routeObserver.subscribe(this, route); } } /// 上层路由 pop 后本路由重新可见:恢复播放 @override void didPopNext() { if (!mounted) return; if (!ref.read(feedTabVisibleProvider)) return; // 游客额度已用尽:保持锁定,不恢复播放 if (ref.read(guestLockedProvider)) return; ActionLogger.I.log('feed_did_pop_next', page: '/home', detail: { 'strategy': _strategy.name, }); final notifier = ref.read(feedProvider(_strategy).notifier); unawaited(() async { await notifier.pool.resume(); await notifier.rebindCurrent(); }()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); routeObserver.unsubscribe(this); _progressTimer?.cancel(); _eventSub?.cancel(); // 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。 // feedProvider 是全局常驻,不处理则播放器池在后台继续出声 ref.read(feedProvider(_strategy).notifier).pool.stopAll(); _pageController.dispose(); super.dispose(); } /// App 退后台挂起、回前台从断点续播。inactive(下拉通知栏等瞬时态) /// 只轻量 pause 当前槽防出声;paused(真退后台)才走 suspend(stop) /// 彻底断下载——避免瞬时 inactive 触发 stop+重载的画面闪烁 @override void didChangeAppLifecycleState(AppLifecycleState state) { final notifier = ref.read(feedProvider(_strategy).notifier); if (state == AppLifecycleState.paused) { _appActive = false; // 先存进度再 suspend(suspend 会 stop 槽位,位置随即清零) unawaited(notifier.persistProgress()); notifier.pool.suspend(); } else if (state == AppLifecycleState.inactive) { unawaited(notifier.persistProgress()); notifier.pool.pauseCurrent(); } else if (state == AppLifecycleState.resumed) { _appActive = true; // 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播);游客锁定时不恢复 if (ref.read(feedTabVisibleProvider) && !ref.read(guestLockedProvider)) { unawaited(() async { await notifier.pool.resume(); await notifier.rebindCurrent(); }()); } } } void _handleEvent(FeedEvent event) { if (!mounted) return; switch (event) { case FeedToastEvent(:final message): showTopToast(context, message); case FeedGuestLimitEvent(): showGuestLimitDialog(context); case FeedPointsInsufficientEvent(:final points): showPointsInsufficientDialog(context, points); } } void _switchStrategy(FeedStrategy next) { if (next == _strategy) return; // 先保存旧策略进度再挂起(suspend 会 stop 槽位,位置随即清零) unawaited(ref.read(feedProvider(_strategy).notifier).persistProgress()); // 挂起旧策略池(防在途 open 完成后自动出声) ref.read(feedProvider(_strategy).notifier).pool.suspend(); unawaited(FeedProgressStore.I.saveLastStrategy(next.name)); setState(() => _strategy = next); // 会话内已有数据的策略由下方跳页逻辑处理,不再走冷启动位置同步 if (ref.read(feedProvider(next)).items.isNotEmpty) { _initialSyncDone.add(next); } ref.read(activeFeedStrategyProvider.notifier).state = next; _eventSub?.cancel(); _eventSub = ref .read(feedProvider(_strategy).notifier) .events .listen(_handleEvent); // 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零 final targetIndex = ref.read(feedProvider(_strategy)).currentIndex; ActionLogger.I.log('feed_switch_strategy', page: '/home', detail: { 'to': next.name, 'target': targetIndex, 'liked': ref.read(feedProvider(FeedStrategy.liked)).currentIndex, 'viewed': ref.read(feedProvider(FeedStrategy.viewed)).currentIndex, }); // 必须等新策略的 PageView 重建完成后再跳页:同步 jumpToPage 触发的是 // 旧 PageView 的 onPageChanged(回调仍指向旧策略 notifier),会把旧策略的 // currentIndex 改成 0,导致切回时从第 1 条开始播 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (_pageController.hasClients) { _pageController.jumpToPage(targetIndex); } // jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play // 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播) if (!ref.read(guestLockedProvider)) { unawaited(() async { final n = ref.read(feedProvider(_strategy).notifier); await n.pool.resume(); await n.rebindCurrent(); }()); } }); } /// 打开评论弹层:打开期间暂停当前播放,关闭后恢复 Future _openComments(VideoItem item) async { final notifier = ref.read(feedProvider(_strategy).notifier); notifier.pool.pauseCurrent(); await showCommentSheet( context, videoId: item.videoId, onCountChanged: (delta) => notifier.adjustCommentCount(item.videoId, delta), ); if (mounted) notifier.pool.playCurrent(); } @override Widget build(BuildContext context) { final state = ref.watch(feedProvider(_strategy)); final notifier = ref.read(feedProvider(_strategy).notifier); final guestLocked = ref.watch(guestLockedProvider); // 登录成功后解除游客锁并恢复当前视频播放(登录态下改为积分/会员计费) ref.listen(authProvider, (previous, next) { if (next.isLoggedIn && ref.read(guestLockedProvider)) { ref.read(guestLockedProvider.notifier).state = false; unawaited(ref.read(feedProvider(_strategy).notifier).rebindCurrent()); } }); // 冷启动续播:列表加载完成后把 PageView 定位到恢复的下标(每个策略一次)。 // Notifier 会在 refresh 时按上次 videoId 恢复 currentIndex,此处只负责跳页, // 跳页触发的 onPageChanged 会把新列表与播放器池对齐 ref.listen(feedProvider(_strategy), (previous, next) { if (next.items.isEmpty) return; if (!_initialSyncDone.add(_strategy)) return; final target = next.currentIndex; if (target <= 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_pageController.hasClients) return; if ((_pageController.page?.round() ?? 0) != target) { _pageController.jumpToPage(target); } }); }); return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // 内容区 if (state.isRefreshing && state.items.isEmpty) const Center( child: CircularProgressIndicator(color: AppColors.primary), ) else if (state.loadFailed && state.items.isEmpty) _buildRetry(notifier) else if (state.items.isEmpty) const Center( child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)), ) else NotificationListener( onNotification: (n) { // 手指开始拖动即静音,避免划动过程中上一个视频仍在出声 if (n is ScrollStartNotification && n.dragDetails != null) { notifier.pool.pauseCurrent(); } return false; }, child: PageView.builder( controller: _pageController, scrollDirection: Axis.vertical, itemCount: state.items.length, onPageChanged: notifier.onPageChanged, itemBuilder: (context, index) { final item = state.items[index]; return FeedVideoPage( key: ValueKey(item.videoId), item: item, slotIndex: notifier.pool.slotOf(index), pool: notifier.pool, isCurrent: index == state.currentIndex, onLike: notifier.toggleLike, onFavorite: notifier.toggleFavorite, onComment: _openComments, onReturnFromPlayer: notifier.rebindCurrent, ); }, ), ), // 顶部:策略 Tab + 搜索入口 SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( children: [ const Spacer(), for (final s in FeedStrategy.values.where( (s) => s != FeedStrategy.search, )) ...[ _StrategyTab( label: s.label, selected: s == _strategy, onTap: () => _switchStrategy(s), ), const SizedBox(width: 16), ], IconButton( icon: const Icon(Icons.search, color: Colors.white), onPressed: () => context.push('/search'), ), ], ), ), ), // 底部:免费剩余次数提示条(≤3 时显示) Positioned( left: 0, right: 0, bottom: 0, child: FreeQuotaBanner(remaining: state.freeRemainingToday), ), // 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看) if (guestLocked) const Positioned.fill(child: GuestLockOverlay()), ], ), ); } Widget _buildRetry(FeedNotifier notifier) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('网络异常', style: TextStyle(color: AppColors.textSecondary)), const SizedBox(height: 12), TextButton( onPressed: notifier.refresh, child: const Text('点击重试', style: TextStyle(color: AppColors.primary)), ), ], ), ); } } /// 顶部策略 Tab class _StrategyTab extends StatelessWidget { const _StrategyTab({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Text( label, style: TextStyle( fontSize: 16, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, color: selected ? Colors.white : AppColors.textSecondary, shadows: const [Shadow(color: Colors.black54, blurRadius: 4)], ), ), ); } }