// 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, this.compact = false, this.inactiveTrackColor = AppColors.border, }); final ValueListenable? valueListenable; final String semanticsLabel; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; final bool showTimes; final bool compact; final Color inactiveTrackColor; @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: widget.compact ? (scrubbing ? 32 : 20) : (scrubbing ? 48 : 44), child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: widget.compact ? (scrubbing ? 3 : 1.5) : (scrubbing ? 4 : 3), activeTrackColor: AppColors.blue, inactiveTrackColor: widget.inactiveTrackColor, thumbColor: AppColors.onPrimary, thumbShape: RoundSliderThumbShape( enabledThumbRadius: widget.compact ? (scrubbing ? 6 : 4) : (scrubbing ? 8 : 6), ), overlayShape: RoundSliderOverlayShape( overlayRadius: widget.compact ? 12 : 18, ), ), child: Semantics( label: widget.semanticsLabel, child: Slider( value: fraction, // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 padding: EdgeInsets.zero, 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'; } }