This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/theme/app_theme.dart';
import '../../core/utils/share_text.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();
}
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
void _share(VideoItem item) => Share.share(buildShareText(item));
@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
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,
onShare: _share,
);
},
),
// 顶部:策略 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)],
),
),
);
}
}
@@ -0,0 +1,483 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/comment_item.dart';
import '../../../providers/auth_provider.dart';
import '../../../providers/comment_provider.dart';
import 'feed_overlays.dart';
/// 打开评论弹层(《01-开发文档》2.6 评论:一级 + 楼中楼二级回复)
/// [onCountChanged] 发表/删除成功后回调(delta=±1),用于同步 Feed 评论计数
Future<void> showCommentSheet(
BuildContext context, {
required int videoId,
required void Function(int delta) onCountChanged,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (_) => CommentSheet(
videoId: videoId,
onCountChanged: onCountChanged,
),
);
}
class CommentSheet extends ConsumerStatefulWidget {
const CommentSheet({
super.key,
required this.videoId,
required this.onCountChanged,
});
final int videoId;
/// 评论计数变化回调(发表 +1 / 删除 -1)
final void Function(int delta) onCountChanged;
@override
ConsumerState<CommentSheet> createState() => _CommentSheetState();
}
class _CommentSheetState extends ConsumerState<CommentSheet> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
final _inputFocus = FocusNode();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
_inputFocus.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 100) {
ref.read(commentProvider(widget.videoId).notifier).loadMore();
}
}
/// 发表(游客守卫:跳登录页)
Future<void> _send() async {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
final content = _inputController.text.trim();
if (content.isEmpty) return;
final notifier = ref.read(commentProvider(widget.videoId).notifier);
try {
final result = await notifier.post(content);
_inputController.clear();
_inputFocus.unfocus();
widget.onCountChanged(1);
if (!mounted) return;
final progress = result.rewardProgress;
if (progress.rewardedThisTime) {
showTopToast(context, '评论奖励 +${progress.rewardPoints} 积分');
}
} on ApiException catch (e) {
if (mounted) showTopToast(context, e.message);
} catch (_) {
if (mounted) showTopToast(context, '发表失败,请重试');
}
}
/// 删除确认(本人评论/本人视频下评论)
Future<void> _confirmDelete(CommentItem comment, {int parentId = 0}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('删除评论', style: TextStyle(fontSize: 16)),
content: const Text('确定删除这条评论吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (ok != true || !mounted) return;
try {
await ref
.read(commentProvider(widget.videoId).notifier)
.remove(comment, parentId: parentId);
widget.onCountChanged(-1);
} on ApiException catch (e) {
if (mounted) showTopToast(context, e.message);
} catch (_) {
if (mounted) showTopToast(context, '删除失败,请重试');
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(commentProvider(widget.videoId));
final notifier = ref.read(commentProvider(widget.videoId).notifier);
return SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
// 头部:标题 + 关闭
SizedBox(
height: 48,
child: Stack(
children: [
Center(
child: Text(
state.total > 0 ? '评论 ${state.total}' : '评论',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
),
],
),
),
const Divider(height: 1),
// 评论列表
Expanded(child: _buildList(state, notifier)),
// 回复目标提示条
if (state.replyTarget != null)
Container(
width: double.infinity,
color: AppColors.background,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'回复 @${state.replyTarget!.nickname}',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
),
GestureDetector(
onTap: () => notifier.setReplyTarget(null),
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
),
// 输入区
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(
left: 12,
right: 12,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 8,
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
focusNode: _inputFocus,
maxLength: 500,
maxLines: 1,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: state.replyTarget == null
? '说点什么...'
: '回复 @${state.replyTarget!.nickname}',
counterText: '',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onTap: () {
// 游客点击输入框:守卫跳登录
if (!ref.read(authProvider).isLoggedIn) {
_inputFocus.unfocus();
context.go('/login');
}
},
onSubmitted: (_) => _send(),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: state.posting ? null : _send,
child: Text(
state.posting ? '发送中' : '发送',
style: TextStyle(
color: state.posting
? AppColors.textWeak
: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildList(CommentState state, CommentNotifier notifier) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
if (state.loadFailed) {
return Center(
child: TextButton(
onPressed: notifier.load,
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
);
}
if (state.items.isEmpty) {
return const Center(
child: Text('还没有评论,快来抢沙发',
style: TextStyle(color: AppColors.textSecondary)),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: state.items.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.all(12),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primary),
),
),
);
}
final comment = state.items[index];
final replies = state.replies[comment.commentId];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommentTile(
comment: comment,
canDelete: _canDelete(comment),
onReply: () => _replyTo(comment, rootId: comment.commentId),
onDelete: () => _confirmDelete(comment),
),
// 回复展开入口
if (comment.replyCount > 0)
Padding(
padding: const EdgeInsets.only(left: 44),
child: GestureDetector(
onTap: () => notifier.toggleReplies(comment),
child: Text(
state.repliesLoading.contains(comment.commentId)
? '加载中...'
: replies == null
? '展开 ${comment.replyCount} 条回复'
: '收起回复',
style: const TextStyle(
color: AppColors.secondary,
fontSize: 12,
),
),
),
),
// 二级回复列表
if (replies != null)
Padding(
padding: const EdgeInsets.only(left: 44, top: 4),
child: Column(
children: [
for (final reply in replies)
CommentTile(
comment: reply,
compact: true,
canDelete: _canDelete(reply),
onReply: () => _replyTo(
reply,
rootId: comment.commentId,
),
onDelete: () => _confirmDelete(
reply,
parentId: comment.commentId,
),
),
if (comment.replyCount > replies.length)
Text(
'共 ${comment.replyCount} 条回复',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
],
);
},
);
}
/// 本人评论可删(本人视频下他人评论的删除依赖视频作者身份,一期仅支持自删)
bool _canDelete(CommentItem comment) {
final user = ref.read(authProvider).user;
return user != null && user.userId == comment.userId;
}
void _replyTo(CommentItem comment, {required int rootId}) {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
ref.read(commentProvider(widget.videoId).notifier).setReplyTarget(
ReplyTarget(
parentId: rootId,
replyToUserId: comment.userId,
nickname: comment.nickname,
),
);
_inputFocus.requestFocus();
}
}
/// 单条评论(一级/二级通用,compact 为二级样式)
class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.comment,
required this.onReply,
required this.onDelete,
this.canDelete = false,
this.compact = false,
});
final CommentItem comment;
final VoidCallback onReply;
final VoidCallback onDelete;
/// 是否可删除(本人评论)
final bool canDelete;
/// 二级回复紧凑样式
final bool compact;
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPress: canDelete ? onDelete : null,
child: Padding(
padding: EdgeInsets.symmetric(vertical: compact ? 4 : 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头像
CircleAvatar(
radius: compact ? 12 : 16,
backgroundColor: AppColors.background,
backgroundImage: comment.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(comment.avatarUrl)
: null,
child: comment.avatarUrl.isEmpty
? Text(
comment.nickname.isNotEmpty
? comment.nickname.characters.first
: '友',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 10 : 13,
),
)
: null,
),
const SizedBox(width: 8),
// 昵称 + 内容 + 操作
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${comment.nickname} · ${_formatTime(comment.createdAt)}',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
const SizedBox(height: 2),
Text(
comment.content,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 2),
GestureDetector(
onTap: onReply,
child: const Text(
'回复',
style: TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
),
],
),
),
],
),
),
);
}
/// 时间紧凑显示:"2026-10-14 10:00:00" → "10-14 10:00";"刚刚"原样
static String _formatTime(String raw) {
if (raw.length >= 16) return raw.substring(5, 16);
return raw;
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
/// 顶部轻提示(扣积分/操作失败等),自动消失,不依赖第三方 Toast 库
void showTopToast(BuildContext context, String message) {
final overlay = Overlay.of(context);
final entry = OverlayEntry(
builder: (_) => Positioned(
top: MediaQuery.of(context).padding.top + 16,
left: 48,
right: 48,
child: Material(
color: Colors.transparent,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(20),
),
child: Text(
message,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
),
),
),
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 2), entry.remove);
}
/// 游客今日免费次数已用完:登录引导(2002)
void showGuestLimitDialog(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('今日免费次数已用完'),
content: const Text('注册解锁更多视频,今日已看 10/10'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('再逛逛', style: TextStyle(color: AppColors.textSecondary)),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
context.go('/login');
},
child: const Text('去登录/注册'),
),
],
),
);
}
/// 积分不足:引导充值 / 开通会员(2001)
void showPointsInsufficientDialog(BuildContext context, int points) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('积分不足'),
content: Text('当前积分 $points,请充值或开通会员后继续观看'),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
context.push('/membership');
},
child: const Text('开通会员', style: TextStyle(color: AppColors.gold)),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
context.push('/recharge');
},
child: const Text('去充值'),
),
],
),
);
}
/// 底部免费剩余次数提示条:游客/非会员且剩余 ≤3 时显示(《01-开发文档》5.2.1)
class FreeQuotaBanner extends StatelessWidget {
const FreeQuotaBanner({super.key, required this.remaining});
/// 今日免费剩余次数;null(未上报过)或 >3 不显示
final int? remaining;
@override
Widget build(BuildContext context) {
final r = remaining;
if (r == null || r > 3) return const SizedBox.shrink();
return SafeArea(
child: Container(
width: double.infinity,
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
r > 0 ? '今日免费观看剩余 $r 次' : '今日免费次数已用完,登录后继续观看',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.gold, fontSize: 12),
),
),
);
}
}
/// 双击点赞的大爱心飘出动画
class LikeBurst extends StatefulWidget {
const LikeBurst({super.key, required this.trigger});
/// 每次双击递增触发动画(0 不显示)
final int trigger;
@override
State<LikeBurst> createState() => LikeBurstState();
}
class LikeBurstState extends State<LikeBurst>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
late final Animation<double> _scale = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.2), weight: 30),
TweenSequenceItem(tween: Tween(begin: 1.2, end: 1.0), weight: 20),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 50),
]).animate(_controller);
late final Animation<double> _opacity = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 70),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
]).animate(_controller);
@override
void didUpdateWidget(LikeBurst oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.trigger != oldWidget.trigger && widget.trigger > 0) {
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.trigger <= 0) return const SizedBox.shrink();
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (_, __) => Opacity(
opacity: _opacity.value,
child: Transform.scale(
scale: _scale.value,
child: const Icon(Icons.favorite, size: 96, color: AppColors.primary),
),
),
),
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/player/feed_player_pool.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
import 'feed_overlays.dart';
import 'interaction_bar.dart';
import 'video_info_section.dart';
import 'video_player_view.dart';
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞
class FeedVideoPage extends ConsumerStatefulWidget {
const FeedVideoPage({
super.key,
required this.item,
required this.slotIndex,
required this.pool,
required this.isCurrent,
required this.onLike,
this.onFavorite,
this.onComment,
this.onShare,
});
final VideoItem item;
final int slotIndex;
final FeedPlayerPool pool;
final bool isCurrent;
/// 点赞回调(由上层接 feedProvider.toggleLike,内部判登录态)
final void Function(int videoId) onLike;
/// 收藏回调(由上层接 feedProvider.toggleFavorite)
final void Function(int videoId)? onFavorite;
/// 评论入口(打开评论弹层)
final void Function(VideoItem item)? onComment;
/// 分享入口(系统分享面板)
final void Function(VideoItem item)? onShare;
@override
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
}
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
int _likeBurstTrigger = 0;
void _onDoubleTap() {
// 游客双击:与点赞按钮一致守卫登录(《01-开发文档》2.1.1 游客不可点赞)
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
setState(() => _likeBurstTrigger++);
// 双击:未点赞等同点赞;已点赞只播动画不改状态
if (!widget.item.isLiked) {
widget.onLike(widget.item.videoId);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.pool.toggleCurrent,
onDoubleTap: _onDoubleTap,
child: Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
// 底部渐变(提升文字可读性)
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 160,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
),
),
// 播放器
VideoPlayerView(
controller: widget.pool.controllerAt(widget.slotIndex),
coverUrl: widget.item.coverUrl,
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
),
// 双击爱心动画
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
// 右侧互动栏
Positioned(
right: 8,
bottom: 96,
child: InteractionBar(
item: widget.item,
onLike: widget.onLike,
onFavorite: widget.onFavorite,
onComment: widget.onComment,
onShare: widget.onShare,
),
),
// 左下信息区
Positioned(
left: 12,
right: 88,
bottom: 24,
child: VideoInfoSection(item: widget.item),
),
],
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:cached_network_image/cached_network_image.dart';
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/auth_provider.dart';
/// 右侧互动栏(《01-开发文档》5.2.1):
/// 作者头像(+关注预留)、点赞(红心+计数)、评论(气泡+计数)、收藏(星标+计数)、分享
class InteractionBar extends ConsumerWidget {
const InteractionBar({
super.key,
required this.item,
required this.onLike,
this.onFavorite,
this.onComment,
this.onShare,
});
final VideoItem item;
final void Function(int videoId) onLike;
/// 收藏回调(与点赞一样由上层接当前策略的 feedProvider)
final void Function(int videoId)? onFavorite;
/// 评论入口(游客也可打开查看,发表时才守卫登录)
final void Function(VideoItem item)? onComment;
/// 分享入口(系统分享面板,无需登录)
final void Function(VideoItem item)? onShare;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 作者头像(关注预留:右下角 + 号占位)
_AuthorAvatar(item: item),
const SizedBox(height: 20),
_ActionItem(
icon: item.isLiked ? Icons.favorite : Icons.favorite_border,
color: item.isLiked ? AppColors.primary : Colors.white,
count: item.likeCount,
onTap: () => _guard(context, ref, () => onLike(item.videoId)),
),
const SizedBox(height: 16),
_ActionItem(
icon: Icons.chat_bubble_outline,
count: item.commentCount,
onTap: () => onComment?.call(item),
),
const SizedBox(height: 16),
_ActionItem(
icon: item.isFavorited ? Icons.star : Icons.star_border,
color: item.isFavorited ? AppColors.gold : Colors.white,
count: item.favoriteCount,
onTap: () => _guard(context, ref, () => onFavorite?.call(item.videoId)),
),
const SizedBox(height: 16),
_ActionItem(
icon: Icons.share_outlined,
count: null,
label: '分享',
onTap: () => onShare?.call(item),
),
],
);
}
/// 登录守卫:未登录跳登录页
void _guard(BuildContext context, WidgetRef ref, VoidCallback action) {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
action();
}
}
class _AuthorAvatar extends StatelessWidget {
const _AuthorAvatar({required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
// 点头像进作者公开主页(游客可看)
return GestureDetector(
onTap: () => context.push('/user/${item.author.userId}'),
child: SizedBox(
width: 48,
height: 56,
child: Stack(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColors.surface,
backgroundImage: item.author.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(item.author.avatarUrl)
: null,
child: item.author.avatarUrl.isEmpty
? Text(
item.author.nickname.isNotEmpty
? item.author.nickname.characters.first
: '友',
style: const TextStyle(color: Colors.white, fontSize: 18),
)
: null,
),
const Positioned(
bottom: 0,
left: 12,
child: Icon(Icons.add_circle, size: 20, color: AppColors.primary),
),
],
),
),
);
}
}
class _ActionItem extends StatelessWidget {
const _ActionItem({
required this.icon,
required this.onTap,
this.count,
this.label,
this.color = Colors.white,
});
final IconData icon;
final VoidCallback onTap;
final int? count;
final String? label;
final Color color;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 2),
Text(
label ?? _formatCount(count ?? 0),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
shadows: [Shadow(color: Colors.black54, blurRadius: 2)],
),
),
],
),
);
}
/// 计数格式化:≥10000 显示 x.xw
static String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
return n.toString();
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
/// 左下信息区(《01-开发文档》5.2.1):
/// 作者昵称、标题(标题后 #标签,点击跳转标签视频页)、描述(展开/收起)
class VideoInfoSection extends StatefulWidget {
const VideoInfoSection({super.key, required this.item});
final VideoItem item;
@override
State<VideoInfoSection> createState() => _VideoInfoSectionState();
}
class _VideoInfoSectionState extends State<VideoInfoSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 昵称
Text(
'@${item.author.nickname}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
const SizedBox(height: 8),
// 标题 + #标签(点击标签跳转标签视频页)
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
if (item.title.isNotEmpty)
Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
for (final tag in item.tags)
GestureDetector(
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
child: Text(
'#$tag',
style: const TextStyle(
color: AppColors.secondary,
fontSize: 15,
fontWeight: FontWeight.w600,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
),
],
),
// 描述(展开/收起)
if (item.description.isNotEmpty) ...[
const SizedBox(height: 6),
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Text(
item.description,
maxLines: _expanded ? null : 1,
overflow: _expanded ? null : TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
),
if (!_expanded && item.description.length > 30)
const Text(
'展开',
style: TextStyle(color: AppColors.textWeak, fontSize: 12),
),
],
],
);
}
}
@@ -0,0 +1,52 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:media_kit_video/media_kit_video.dart';
/// media_kit Video 包装:封面兜底、缓冲转圈、暂停态大播放图标
class VideoPlayerView extends StatelessWidget {
const VideoPlayerView({
super.key,
required this.controller,
required this.coverUrl,
required this.playing,
});
final VideoController controller;
final String coverUrl;
final bool playing;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// 播放器(无控制条,纯画面)
Video(
controller: controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
// 封面兜底:加载/无地址时可见(HLS 起播后被画面覆盖)
if (coverUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
// 暂停态大播放图标
if (!playing)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_arrow_rounded,
size: 88,
color: Colors.white70,
),
),
),
],
);
}
}