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

222 lines
6.4 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.playing,
});
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) {
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.playing)
const IgnorePointer(
child: 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,
),
),
],
);
}
}
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;
}