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 createState() => _FeedVideoPageState(); } class _FeedVideoPageState extends ConsumerState { int _likeBurstTrigger = 0; Map? _seriesData; static const _rates = [1.0, 1.5, 2.0, 3.0]; @override void initState() { super.initState(); _loadSeries(); } Future _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 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( 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 _share() async { final text = '${widget.item.title}\n${widget.item.videoUrl}'; await Share.share(text, subject: widget.item.title); } void _showSeriesSheet() { showModalBottomSheet( 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; 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 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), ], ), ), ], ), ), ); } }