Files
video/app/lib/screens/feed/widgets/feed_video_page.dart
T
2026-10-24 00:04:50 +08:00

494 lines
18 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 '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/media/download_manager.dart';
import '../../../core/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
import '../../../services/video_service.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.onReturnFromPlayer,
});
final VideoItem item;
final int slotIndex;
final FeedPlayerPool pool;
final bool isCurrent;
final void Function(int videoId) onLike;
final void Function(int videoId)? onFavorite;
final void Function(VideoItem item)? onComment;
/// 从全屏播放器返回后的恢复回调(重新绑定 Feed 当前页)
final VoidCallback? onReturnFromPlayer;
@override
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
}
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
int _likeBurstTrigger = 0;
Map<String, dynamic>? _seriesData;
/// 清屏模式:隐藏右侧互动栏与底部信息区,只留画面和进度条。
/// 单击屏幕仍是暂停/继续;恢复显示只能通过长按菜单的「恢复清屏」
bool _cleanMode = false;
static const _rates = [1.0, 1.5, 2.0, 3.0];
@override
void initState() {
super.initState();
_loadSeries();
}
Future<void> _loadSeries() async {
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
if (!mounted) return;
setState(() => _seriesData = data);
}
bool get _hasSeries =>
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? '';
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
void _onDoubleTap() {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
setState(() => _likeBurstTrigger++);
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
}
/// 倍速标签:1.0→1X,1.5→1.5X
String _rateLabel(double r) =>
r == r.roundToDouble() ? '${r.toInt()}X' : '${r}X';
void _onLongPress() {
final player = widget.pool.slotAt(widget.slotIndex).player;
final currentRate = player.state.rate;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题 + 当前倍速
Row(
children: [
const Icon(Icons.speed, color: AppColors.primary, size: 20),
const SizedBox(width: 6),
const Text('倍速播放',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
const Spacer(),
Text('当前 ${_rateLabel(currentRate)}',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
),
const SizedBox(height: 14),
// 四档倍速:1X / 1.5X / 2X / 3X
Row(
children: [
for (final r in _rates) ...[
Expanded(
child: GestureDetector(
onTap: () {
player.setRate(r);
Navigator.of(sheetContext).pop();
},
child: Container(
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: (currentRate - r).abs() < 0.01
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
border: Border.all(
color: (currentRate - r).abs() < 0.01
? AppColors.primary
: Colors.transparent,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_rateLabel(r),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: (currentRate - r).abs() < 0.01
? AppColors.primary
: Colors.black87,
),
),
),
),
),
if (r != _rates.last) const SizedBox(width: 10),
],
],
),
const SizedBox(height: 16),
const Divider(height: 1),
// 分享
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.share_outlined),
title: const Text('分享'),
onTap: () {
Navigator.of(sheetContext).pop();
_share();
},
),
// 清屏 / 恢复清屏:隐藏右侧互动栏与底部信息,只留进度条。
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(_cleanMode
? Icons.fullscreen
: Icons.fullscreen_exit),
title: Text(_cleanMode ? '恢复清屏' : '清屏'),
subtitle: Text(_cleanMode
? '重新显示点赞、评论、收藏、作者与标题'
: '隐藏点赞、评论、收藏、作者与标题'),
onTap: () {
Navigator.of(sheetContext).pop();
setState(() => _cleanMode = !_cleanMode);
showTopToast(
context, _cleanMode ? '已清屏,长按视频可恢复' : '已恢复显示');
},
),
// 下载(已下载/下载中置灰)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded = dm.items
.any((e) => e.videoId == widget.item.videoId);
final downloading =
dm.progress.containsKey(widget.item.videoId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(widget.item)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
],
),
),
),
);
}
Future<void> _share() async {
final text = '${widget.item.title}\n${widget.item.videoUrl}';
await Share.share(text, subject: widget.item.title);
}
void _showSeriesSheet() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, controller) => Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40, height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_seriesTitle,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'共 ${_episodes.length} 集',
style: TextStyle(
fontSize: 12, color: Colors.grey[600]),
),
],
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
controller: controller,
itemCount: _episodes.length,
itemBuilder: (_, i) {
final ep = _episodes[i] as Map<String, dynamic>;
final isCurrent = ep['video_id'] == widget.item.videoId;
return ListTile(
leading: Container(
width: 40, height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isCurrent
? AppColors.primary.withValues(alpha: 0.15)
: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${ep['episode_no'] ?? i + 1}',
style: TextStyle(
fontWeight: FontWeight.w700,
color: isCurrent ? AppColors.primary : Colors.black87,
),
),
),
title: Text(
ep['title'] as String? ?? '',
maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight:
isCurrent ? FontWeight.w600 : FontWeight.normal,
color: isCurrent ? AppColors.primary : Colors.black87,
),
),
trailing: isCurrent
? const Icon(Icons.play_circle_filled,
color: AppColors.primary, size: 20)
: null,
onTap: () => _switchToEpisode(ep),
);
},
),
),
],
),
),
),
);
}
void _switchToEpisode(Map<String, dynamic> ep) {
Navigator.of(context).pop();
final newItem = VideoItem(
videoId: ep['video_id'] as int,
title: ep['title'] as String? ?? '',
coverUrl: ep['cover_url'] as String? ?? '',
videoUrl: ep['video_url'] as String? ?? '',
duration: (ep['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author,
);
// 彻底停止 Feed 层播放器:stopAll 断音 + 断缓存下载(pause 不停止缓冲)
widget.pool.stopAll();
// ignore: use_build_context_synchronously
context.push('/player', extra: newItem).then((_) {
// 从播放器返回:恢复 Feed 当前页(槽位已清空,需重新绑定)
if (mounted) widget.onReturnFromPlayer?.call();
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
// 单击始终暂停/继续(清屏态也一样),恢复清屏需走长按菜单
onTap: widget.pool.toggleCurrent,
onDoubleTap: _onDoubleTap,
onLongPress: _onLongPress,
child: Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
// 底部渐变(提升文字可读性):清屏时一并淡出
Positioned(
left: 0, right: 0, bottom: 0, height: 160,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _cleanMode ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
),
),
),
// 播放器(含抖音风格细进度条)——清屏时仍保留
VideoPlayerView(
pool: widget.pool,
slotIndex: widget.slotIndex,
coverUrl: widget.item.coverUrl,
isCurrent: widget.isCurrent,
durationSeconds: widget.item.duration,
),
// 双击爱心动画
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
Positioned(
right: 8, bottom: 96,
child: _HidableOverlay(
hidden: _cleanMode,
child: InteractionBar(
item: widget.item,
onLike: widget.onLike,
onFavorite: widget.onFavorite,
onComment: widget.onComment,
),
),
),
// 左下信息区 + 系列标题(系列标题在信息区上方,不重叠)
Positioned(
left: 12, right: 88, bottom: 24,
child: _HidableOverlay(
hidden: _cleanMode,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 系列标题(当前视频属于系列时显示,点击弹出系列列表)
if (_hasSeries)
GestureDetector(
onTap: _showSeriesSheet,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.playlist_play, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
_seriesTitle,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 2),
const Icon(Icons.chevron_right, color: Colors.white, size: 14),
],
),
),
),
// 视频信息(昵称/标题/描述)
VideoInfoSection(item: widget.item),
],
),
),
),
],
),
),
);
}
}
/// 可隐藏浮层:淡出 + 禁用点击。隐藏时仍占位但不拦截手势,
/// 保证点击/长按能穿透到下层的手势层
class _HidableOverlay extends StatelessWidget {
const _HidableOverlay({required this.hidden, required this.child});
final bool hidden;
final Widget child;
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: hidden,
child: AnimatedOpacity(
opacity: hidden ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: child,
),
);
}
}