Files
video/app/lib/screens/feed/widgets/video_player_view.dart
T
2026-10-17 19:04:05 +08:00

297 lines
9.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:media_kit_video/media_kit_video.dart';
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.pool,
required this.slotIndex,
required this.coverUrl,
required this.isCurrent,
this.durationSeconds = 0,
});
final FeedPlayerPool pool;
final int slotIndex;
final String coverUrl;
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
final bool isCurrent;
/// DB 里的视频时长(秒):流 duration 异常(如被当直播流报 0)时兜底,
/// 保证进度条/拖动可用
final int durationSeconds;
@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;
double _seekValue = 0; // 拖动中的视觉值(0~1),拖动期间直接控制 painter
@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);
});
}
/// 有效时长:流上报优先,异常(duration=0)回退 DB 时长
int get _totalMs {
final streamMs = _duration.inMilliseconds;
return streamMs > 0 ? streamMs : widget.durationSeconds * 1000;
}
double get _value {
if (_seeking) return _seekValue;
final total = _totalMs;
if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total);
return cur / total;
}
void _onSeekStart(double v) {
setState(() {
_seeking = true;
_seekValue = v;
});
}
void _onSeek(double v) {
setState(() => _seekValue = v);
}
void _onSeekEnd(double v) {
setState(() => _seeking = false);
final totalMs = _totalMs;
if (totalMs > 0) {
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
}
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// 底层:纯画面(media_kit 官方 Video,无控制条)
Video(
controller: _slot.controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
// 封面兜底:position==0 时显示(仅未渲染第一帧)
StreamBuilder<Duration>(
stream: _slot.player.stream.position,
initialData: _slot.player.state.position,
builder: (_, snapshot) {
final pos = snapshot.data ?? Duration.zero;
final showCover =
widget.coverUrl.isNotEmpty && pos == Duration.zero;
return showCover
? Image.network(
widget.coverUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
)
: const SizedBox.shrink();
},
),
// 暂停态大播放图标:只在当前页、且该槽位确实处于暂停态时显示
if (widget.isCurrent)
IgnorePointer(
child: StreamBuilder<bool>(
stream: _slot.player.stream.playing,
initialData: _slot.player.state.playing,
builder: (_, snapshot) {
final playing = snapshot.data ?? false;
if (playing) return const SizedBox.shrink();
return const Center(
child: Icon(
Icons.play_arrow_rounded,
size: 88, color: Colors.white70,
),
);
},
),
),
// 抖音风格细进度条(贴底,常驻;拖动时显示目标时间气泡)
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
value: _value,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
totalMs: _totalMs,
),
),
],
);
}
}
class _ThinProgressBar extends StatefulWidget {
const _ThinProgressBar({
required this.value,
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
this.totalMs = 0,
});
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动气泡显示目标位置用时/分/秒
final int totalMs;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
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: SizedBox(
height: 32, // 触摸热区 32px(视觉 2px,好点)
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Align(
alignment: Alignment.bottomCenter,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
),
),
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
),
),
);
},
);
}
}
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;
}