297 lines
9.3 KiB
Dart
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;
|
|
}
|