This commit is contained in:
lzcode
2026-10-16 16:05:01 +08:00
parent 24d63e6702
commit 9962136fc8
19 changed files with 1425 additions and 208 deletions
-6
View File
@@ -3,10 +3,8 @@ 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';
@@ -99,9 +97,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
if (mounted) notifier.pool.playCurrent();
}
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
void _share(VideoItem item) => Share.share(buildShareText(item));
@override
Widget build(BuildContext context) {
final state = ref.watch(feedProvider(_strategy));
@@ -139,7 +134,6 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
onLike: notifier.toggleLike,
onFavorite: notifier.toggleFavorite,
onComment: _openComments,
onShare: _share,
);
},
),
@@ -1,8 +1,10 @@
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/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
import 'feed_overlays.dart';
@@ -11,7 +13,7 @@ import 'video_info_section.dart';
import 'video_player_view.dart';
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞,长按弹倍速/分享
class FeedVideoPage extends ConsumerStatefulWidget {
const FeedVideoPage({
super.key,
@@ -22,26 +24,16 @@ class FeedVideoPage extends ConsumerStatefulWidget {
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();
}
@@ -49,24 +41,98 @@ class FeedVideoPage extends ConsumerStatefulWidget {
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
int _likeBurstTrigger = 0;
static const _rates = [1.0, 1.25, 1.5, 2.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);
}
if (!widget.item.isLiked) widget.onLike(widget.item.videoId);
}
void _onLongPress() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) {
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.speed, color: AppColors.primary),
title: const Text('倍速播放'),
subtitle: Text('当前 ${currentRate.toStringAsFixed(2)}x'),
onTap: () {
Navigator.of(context).pop();
_showRateSheet();
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('分享'),
onTap: () {
Navigator.of(context).pop();
_share();
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
void _showRateSheet() {
final currentRate = widget.pool.slotAt(widget.slotIndex).player.state.rate;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final r in _rates)
ListTile(
title: Center(child: Text('${r}x')),
trailing: (currentRate - r).abs() < 0.01
? const Icon(Icons.check, color: AppColors.primary)
: null,
onTap: () {
widget.pool.slotAt(widget.slotIndex).player.setRate(r);
Navigator.of(context).pop();
},
),
const SizedBox(height: 8),
],
),
),
);
}
Future<void> _share() async {
final text = '${widget.item.title}\n${widget.item.videoUrl}';
await Share.share(text, subject: widget.item.title);
}
@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(
@@ -74,10 +140,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
children: [
// 底部渐变(提升文字可读性)
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 160,
left: 0, right: 0, bottom: 0, height: 160,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
@@ -91,9 +154,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
),
),
// 播放器
// 播放器(含抖音风格细进度条)
VideoPlayerView(
controller: widget.pool.controllerAt(widget.slotIndex),
pool: widget.pool,
slotIndex: widget.slotIndex,
coverUrl: widget.item.coverUrl,
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
),
@@ -101,24 +165,20 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
// 双击爱心动画
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
// 右侧互动栏
// 右侧互动栏(头像/点赞/评论/收藏 — 分享已移至长按菜单)
Positioned(
right: 8,
bottom: 96,
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,
left: 12, right: 88, bottom: 24,
child: VideoInfoSection(item: widget.item),
),
],
@@ -58,13 +58,6 @@ class InteractionBar extends ConsumerWidget {
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),
),
],
);
}
@@ -1,67 +1,221 @@
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 包装:封面兜底、缓冲转圈、暂停态大播放图标
///
/// 封面仅在视频尚未渲染出第一帧时显示(player state 仍在初始 idle),
/// 一旦 HLS 起播第一帧,封面立即淡出,避免「有声音没画面被封面挡住」。
class VideoPlayerView extends StatelessWidget {
import '../../../core/player/feed_player_pool.dart';
/// Feed 用的抖音风格视频播放器:
/// - 覆盖 Video(controller: pool.controllerAt(slot))
/// - 底部一条 2px 细线进度条(已播白 / 未播半透明白)
/// - 按住拖动变厚 + 出现 thumb,松手 seek 后恢复
class VideoPlayerView extends StatefulWidget {
const VideoPlayerView({
super.key,
required this.controller,
required this.pool,
required this.slotIndex,
required this.coverUrl,
required this.playing,
});
final VideoController controller;
final FeedPlayerPool pool;
final int slotIndex;
final String coverUrl;
final bool playing;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
}
class _VideoPlayerViewState extends State<VideoPlayerView> {
late final PlayerSlot _slot = widget.pool.slotAt(widget.slotIndex);
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
bool _seeking = false;
@override
void initState() {
super.initState();
_slot.player.stream.position.listen((p) {
if (mounted && !_seeking) setState(() => _position = p);
});
_slot.player.stream.duration.listen((d) {
if (mounted) setState(() => _duration = d);
});
}
double get _value {
final total = _duration.inMilliseconds;
if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total);
return cur / total;
}
void _onSeekStart(double v) => setState(() => _seeking = true);
void _onSeek(double v) => setState(() {});
void _onSeekEnd(double v) {
_seeking = false;
final totalMs = _duration.inMilliseconds;
if (totalMs > 0) {
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
}
}
@override
Widget build(BuildContext context) {
final player = controller.player;
return Stack(
fit: StackFit.expand,
children: [
// 播放器(无控制条,纯画面)
// 底层:纯画面(media_kit 官方 Video,无控制条)
Video(
controller: controller,
controller: _slot.controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
// 封面兜底:仅在视频尚未开始渲染时显示
// 封面兜底:position==0 时显示(仅未渲染第一帧)
StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
stream: _slot.player.stream.position,
initialData: _slot.player.state.position,
builder: (_, snapshot) {
final pos = snapshot.data ?? Duration.zero;
// position == 0 说明还没渲染出第一帧(含初始加载和播完后)
final showCover =
coverUrl.isNotEmpty && pos == Duration.zero;
widget.coverUrl.isNotEmpty && pos == Duration.zero;
return showCover
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
? Image.network(
widget.coverUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
)
: const SizedBox.shrink();
},
),
// 暂停态大播放图标
if (!playing)
if (!widget.playing)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_arrow_rounded,
size: 88,
color: Colors.white70,
size: 88, color: Colors.white70,
),
),
),
// 抖音风格细进度条(贴底,常驻)
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
value: _value,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
),
),
],
);
}
}
class _ThinProgressBar extends StatefulWidget {
const _ThinProgressBar({
required this.value,
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
});
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false;
double _dragValue = 0;
void _update(Offset local, double width) {
final v = (local.dx / width).clamp(0.0, 1.0);
_dragValue = v;
widget.onChanged(v);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (_, constraints) {
final w = constraints.maxWidth;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (d) {
setState(() => _dragging = true);
widget.onChangeStart(widget.value);
_update(d.localPosition, w);
},
onHorizontalDragUpdate: (d) => _update(d.localPosition, w),
onHorizontalDragEnd: (_) {
setState(() => _dragging = false);
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
},
onTapDown: (d) {
setState(() => _dragging = true);
widget.onChangeStart(widget.value);
_update(d.localPosition, w);
},
onTapUp: (_) {
setState(() => _dragging = false);
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
},
child: Container(
height: 32, // 触摸热区 32px(视觉 2px,好点)
alignment: Alignment.bottomCenter,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
),
),
);
},
);
}
}
class _ProgressPainter extends CustomPainter {
_ProgressPainter({required this.value, required this.dragging});
final double value;
final bool dragging;
@override
void paint(Canvas canvas, Size size) {
final h = dragging ? 3.0 : 2.0;
final y = size.height / 2;
final track = Paint()
..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22)
..strokeCap = StrokeCap.round
..strokeWidth = h;
canvas.drawLine(Offset(0, y), Offset(size.width, y), track);
if (value > 0) {
final played = Paint()
..color = Colors.white
..strokeCap = StrokeCap.round
..strokeWidth = h;
canvas.drawLine(Offset(0, y), Offset(value * size.width, y), played);
}
if (dragging) {
canvas.drawCircle(
Offset(value * size.width, y), 6,
Paint()..color = Colors.white..style = PaintingStyle.fill,
);
}
}
@override
bool shouldRepaint(covariant _ProgressPainter old) =>
value != old.value || dragging != old.dragging;
}