Files
video/app/lib/screens/feed/feed_screen.dart
T
2026-10-25 20:30:33 +08:00

374 lines
14 KiB
Dart
Raw 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 '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<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen>
with WidgetsBindingObserver, RouteAware {
FeedStrategy _strategy = FeedStrategy.recommend;
final PageController _pageController = PageController();
StreamSubscription<FeedEvent>? _eventSub;
/// 已完成首屏位置同步的策略(冷启动续播跳页只需一次)
final Set<FeedStrategy> _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<void> _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<AuthState>(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<FeedState>(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<ScrollNotification>(
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)],
),
),
);
}
}