import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../models/video_item.dart'; import '../../providers/feed_provider.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 { FeedStrategy _strategy = FeedStrategy.recommend; final PageController _pageController = PageController(); StreamSubscription? _eventSub; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); // 登记当前策略,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(); }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _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) { notifier.pool.suspend(); } else if (state == AppLifecycleState.inactive) { notifier.pool.pauseCurrent(); } else if (state == AppLifecycleState.resumed) { // 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播) if (ref.read(feedTabVisibleProvider)) { 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; // 挂起旧策略池(防在途 open 完成后自动出声) ref.read(feedProvider(_strategy).notifier).pool.suspend(); setState(() => _strategy = next); ref.read(activeFeedStrategyProvider.notifier).state = next; _eventSub?.cancel(); _eventSub = ref .read(feedProvider(_strategy).notifier) .events .listen(_handleEvent); _pageController.jumpToPage(0); // jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play // 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { 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); 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) ...[ _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), ), ], ), ); } 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)], ), ), ); } }