1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user