439 lines
16 KiB
Dart
439 lines
16 KiB
Dart
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;
|
||
|
||
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();
|
||
},
|
||
),
|
||
|
||
// 下载(已下载/下载中置灰)
|
||
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: [
|
||
// 底部渐变(提升文字可读性)
|
||
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(
|
||
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: InteractionBar(
|
||
item: widget.item,
|
||
onLike: widget.onLike,
|
||
onFavorite: widget.onFavorite,
|
||
onComment: widget.onComment,
|
||
),
|
||
),
|
||
|
||
// 左下信息区 + 系列标题(系列标题在信息区上方,不重叠)
|
||
Positioned(
|
||
left: 12, right: 88, bottom: 24,
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|