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 createState() => _VideoPlayerViewState(); } class _VideoPlayerViewState extends State { 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( 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( 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 onChanged; final ValueChanged onChangeStart; final ValueChanged 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; }