// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; /// 1.5px player scrubber with a buffered segment and white thumb. class CardSeekBar extends StatefulWidget { const CardSeekBar({ super.key, required this.controller, required this.semanticsLabel, required this.episodeLabel, required this.totalEpisodes, this.onScrubStart, this.onSeek, }); final VideoPlayerController? controller; final String semanticsLabel; final String episodeLabel; final int totalEpisodes; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; @override State createState() => _CardSeekBarState(); } class _CardSeekBarState extends State { final ValueNotifier _preview = ValueNotifier(null); @override void dispose() { _preview.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final controller = widget.controller; if (controller == null) { return _track(context, const VideoPlayerValue.uninitialized()); } return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { return ValueListenableBuilder( valueListenable: _preview, builder: (context, _, _) => _track(context, value), ); }, ); } Widget _track(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; final enabled = video.isInitialized && total > 0 && widget.onSeek != null; final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) : 0.0; final fraction = _preview.value ?? played; final buffered = _bufferedFraction(video); final previewPosition = _preview.value == null ? video.position : Duration(milliseconds: (fraction * total).round()); return Column( key: const Key('card-player-progress-bar'), mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 24, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 1.5, activeTrackColor: AppColors.blue, inactiveTrackColor: Colors.white.withValues(alpha: 0.15), thumbColor: Colors.white, overlayColor: AppColors.blue.withValues(alpha: 0.16), trackShape: _BufferedTrackShape(buffered: buffered), thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), ), child: Semantics( label: widget.semanticsLabel, child: Slider( key: const Key('card-player-progress'), value: fraction, onChangeStart: enabled ? (value) { _preview.value = value; widget.onScrubStart?.call(); } : null, onChanged: enabled ? (value) => _preview.value = value : null, onChangeEnd: enabled ? (value) => unawaited(_finish(value, video.duration)) : null, ), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Row( children: [ Text( _format(previewPosition), key: const Key('card-player-time-current'), style: const TextStyle( color: AppColors.cream, fontSize: 10, fontWeight: FontWeight.w600, ), ), Expanded( child: Center( child: Text( '${widget.episodeLabel} / ${widget.totalEpisodes}', key: const Key('card-player-episode'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.subtle, fontSize: 10, ), ), ), ), Text( _format(video.duration), key: const Key('card-player-time-total'), style: const TextStyle(color: AppColors.subtle, fontSize: 10), ), ], ), ), ], ); } Future _finish(double fraction, Duration duration) async { final source = widget.controller; try { final total = duration.inMilliseconds; final target = total > 250 ? (fraction * total).round().clamp(0, total - 250) : 0; await widget.onSeek?.call(Duration(milliseconds: target)); } finally { if (mounted && widget.controller == source) _preview.value = null; } } double _bufferedFraction(VideoPlayerValue video) { final total = video.duration.inMilliseconds; if (total <= 0 || video.buffered.isEmpty) return 0; var end = Duration.zero; for (final range in video.buffered) { if (range.end > end) end = range.end; } return (end.inMilliseconds / total).clamp(0.0, 1.0); } String _format(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } } class _BufferedTrackShape extends SliderTrackShape with BaseSliderTrackShape { const _BufferedTrackShape({required this.buffered}); final double buffered; @override Rect getPreferredRect({ required RenderBox parentBox, Offset offset = Offset.zero, required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, }) { final thumbWidth = sliderTheme.thumbShape?.getPreferredSize(isEnabled, isDiscrete).width ?? 0; final overlayWidth = sliderTheme.overlayShape ?.getPreferredSize(isEnabled, isDiscrete) .width ?? 0; final trackHeight = sliderTheme.trackHeight ?? 1.5; final trackLeft = offset.dx + overlayWidth / 2; final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; final trackWidth = parentBox.size.width - (overlayWidth - thumbWidth) - thumbWidth; return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); } @override void paint( PaintingContext context, Offset offset, { required RenderBox parentBox, required SliderThemeData sliderTheme, required Animation enableAnimation, required TextDirection textDirection, required Offset thumbCenter, Offset? secondaryOffset, bool isEnabled = false, bool isDiscrete = false, bool isSecondaryTrack = false, }) { final rect = getPreferredRect( parentBox: parentBox, offset: offset, sliderTheme: sliderTheme, isEnabled: isEnabled, isDiscrete: isDiscrete, ); final radius = Radius.circular(rect.height); final canvas = context.canvas; canvas.drawRRect( RRect.fromRectAndRadius(rect, radius), Paint()..color = Colors.white.withValues(alpha: 0.15), ); if (buffered > 0) { canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( rect.left, rect.top, rect.width * buffered, rect.height, ), radius, ), Paint()..color = Colors.white.withValues(alpha: 0.30), ); } final played = ((thumbCenter.dx - rect.left) / rect.width).clamp(0.0, 1.0); if (played > 0) { canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(rect.left, rect.top, rect.width * played, rect.height), radius, ), Paint()..color = AppColors.blue, ); } } }