263 lines
9.0 KiB
Dart
263 lines
9.0 KiB
Dart
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<FeedScreen> createState() => _FeedScreenState();
|
||
}
|
||
|
||
class _FeedScreenState extends ConsumerState<FeedScreen>
|
||
with WidgetsBindingObserver {
|
||
FeedStrategy _strategy = FeedStrategy.recommend;
|
||
final PageController _pageController = PageController();
|
||
StreamSubscription<FeedEvent>? _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<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);
|
||
|
||
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) ...[
|
||
_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)],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|