// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; /// Keeps seeks within the playable range so dragging to the edge does not /// trigger the player's completion callback before playback resumes. Duration seekPositionForFraction(double fraction, Duration duration) { final total = duration.inMilliseconds; if (total <= 0) return Duration.zero; final lastPlayable = total > 250 ? total - 250 : total - 1; final requested = (fraction.clamp(0.0, 1.0) * total).round(); return Duration(milliseconds: requested.clamp(0, lastPlayable)); } class SeekProgressBar extends StatefulWidget { const SeekProgressBar({ super.key, required this.valueListenable, required this.semanticsLabel, this.onScrubStart, this.onSeek, this.showTimes = true, }); final ValueListenable? valueListenable; final String semanticsLabel; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; final bool showTimes; @override State createState() => _SeekProgressBarState(); } class _SeekProgressBarState extends State { final _previewFraction = ValueNotifier(null); @override void didUpdateWidget(covariant SeekProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.valueListenable != widget.valueListenable) { _previewFraction.value = null; } } @override void dispose() { _previewFraction.dispose(); super.dispose(); } Future _finish(double fraction, Duration duration) async { final source = widget.valueListenable; _previewFraction.value = fraction; try { await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); } finally { if (mounted && widget.valueListenable == source) { _previewFraction.value = null; } } } @override Widget build(BuildContext context) { final source = widget.valueListenable; if (source == null) { return ValueListenableBuilder( valueListenable: _previewFraction, builder: (context, _, _) => _buildTrack(context, const VideoPlayerValue.uninitialized()), ); } return ValueListenableBuilder( valueListenable: source, builder: (context, value, _) => ValueListenableBuilder( valueListenable: _previewFraction, builder: (context, _, _) => _buildTrack(context, value), ), ); } Widget _buildTrack(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 = _previewFraction.value ?? played; final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, video.duration); final scrubbing = _previewFraction.value != null; return Column( mainAxisSize: MainAxisSize.min, children: [ if (widget.showTimes && scrubbing) Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '${_format(previewPosition)} / ${_format(video.duration)}', textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2, ), ), ), SizedBox( height: scrubbing ? 48 : 44, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: scrubbing ? 4 : 3, activeTrackColor: AppColors.blueSoft, inactiveTrackColor: Colors.white30, thumbColor: Colors.white, thumbShape: RoundSliderThumbShape( enabledThumbRadius: scrubbing ? 8 : 6, ), overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), ), child: Semantics( label: widget.semanticsLabel, child: Slider( value: fraction, onChangeStart: enabled ? (value) { _previewFraction.value = value; widget.onScrubStart?.call(); } : null, onChanged: enabled ? (value) => _previewFraction.value = value : null, onChangeEnd: enabled ? (value) => unawaited(_finish(value, video.duration)) : null, ), ), ), ), ], ); } 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'; } }