231 lines
7.3 KiB
Dart
231 lines
7.3 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);
|
|
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
|
|
_eventSub = ref
|
|
.read(feedProvider(_strategy).notifier)
|
|
.events
|
|
.listen(_handleEvent);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_eventSub?.cancel();
|
|
_pageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// App 退后台暂停、回前台恢复播放
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
final notifier = ref.read(feedProvider(_strategy).notifier);
|
|
if (state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.inactive) {
|
|
notifier.pool.pauseCurrent();
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
notifier.pool.playCurrent();
|
|
}
|
|
}
|
|
|
|
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;
|
|
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
|
|
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
|
|
setState(() => _strategy = next);
|
|
_eventSub?.cancel();
|
|
_eventSub = ref
|
|
.read(feedProvider(_strategy).notifier)
|
|
.events
|
|
.listen(_handleEvent);
|
|
_pageController.jumpToPage(0);
|
|
}
|
|
|
|
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
|
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,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// 顶部:策略 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)],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|