From 19117a041a3e729ede9b4c910d812a87101327b9 Mon Sep 17 00:00:00 2001 From: orz12 Date: Thu, 29 Feb 2024 21:00:17 +0800 Subject: [PATCH] =?UTF-8?q?mod:=20=E6=B1=89=E5=8C=96=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1+=E6=97=A0=E9=9A=9C=E7=A2=8D=E6=89=8B=E5=8A=BF?= =?UTF-8?q?=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/audio_video_progress_bar.dart | 1137 +++++++++++++++++ 1 file changed, 1137 insertions(+) create mode 100644 lib/common/widgets/audio_video_progress_bar.dart diff --git a/lib/common/widgets/audio_video_progress_bar.dart b/lib/common/widgets/audio_video_progress_bar.dart new file mode 100644 index 00000000..8dd0d9a7 --- /dev/null +++ b/lib/common/widgets/audio_video_progress_bar.dart @@ -0,0 +1,1137 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// This is where the current time and total time labels should appear in +/// relation to the progress bar. +enum TimeLabelLocation { + /// The time is displayed above the progress bar. + /// + /// | 01:23 05:00 | + /// | -------O---------------- | + above, + + /// The time is displayed below the progress bar. + /// + /// | -------O---------------- | + /// | 01:23 05:00 | + below, + + /// The time is displayed on the sides of the progress bar. + /// + /// | 01:23 -------O---------------- 05:00 | + sides, + + /// The time is not displayed. + /// + /// | -------O---------------- | + none, +} + +/// The time label on the right hand side can be shown as the [totalTime] or as +/// the [remainingTime]. If the choice is [remainingTime] then this will be +/// shown as a negative number. +/// +/// +enum TimeLabelType { + /// The time label on the right shows the total time. + /// + /// | -------O---------------- | + /// | 01:23 05:00 | + totalTime, + + /// The time label on the right shows the remaining time as a + /// negative number. + /// + /// | -------O---------------- | + /// | 01:23 -03:37 | + remainingTime, +} + +/// The shape of the progress bar at the left and right ends. +enum BarCapShape { + /// The left and right ends of the bar are round. + round, + + /// The left and right ends of the bar are square. + square, +} + +/// A progress bar widget to show or set the location of the currently +/// playing audio or video content. +/// +/// This widget does not itself play audio or video content, but you can +/// use it in conjunction with an audio plugin. It is a more convenient +/// replacement for the Flutter Slider widget. +class ProgressBar extends LeafRenderObjectWidget { + /// You must set the current audio or video duration [progress] and also + /// the [total] duration. Optionally set the [buffered] content progress + /// as well. + /// + /// When a user drags the thumb to a new location you can be notified + /// by the [onSeek] callback so that you can update your audio/video player. + const ProgressBar({ + Key? key, + required this.progress, + required this.total, + this.buffered, + this.onSeek, + this.onDragStart, + this.onDragUpdate, + this.onDragEnd, + this.barHeight = 5.0, + this.baseBarColor, + this.progressBarColor, + this.bufferedBarColor, + this.barCapShape = BarCapShape.round, + this.thumbRadius = 10.0, + this.thumbColor, + this.thumbGlowColor, + this.thumbGlowRadius = 30.0, + this.thumbCanPaintOutsideBar = true, + this.timeLabelLocation, + this.timeLabelType, + this.timeLabelTextStyle, + this.timeLabelPadding = 0.0, + }) : super(key: key); + + /// The elapsed playing time of the media. + /// + /// This should not be greater than the [total] time. + final Duration progress; + + /// The total duration of the media. + final Duration total; + + /// The currently buffered content of the media. + /// + /// This is useful for streamed content. If you are playing a local file + /// then you can leave this out. + final Duration? buffered; + + /// A callback when user moves the thumb. + /// + /// When the user moved the thumb on the progress bar this callback will + /// run. It will not run until after the user has finished the touch event. + /// + /// You will get the chosen duration to start playing at which you can pass + /// on to your media player. + /// + /// If you want continuous duration updates as the user moves the thumb, + /// see [onDragUpdate], where the provided [ThumbDragDetails] has a + /// `timeStamp` with the seek duration on it. + final ValueChanged? onSeek; + + /// A callback when the user starts to move the thumb. + /// + /// This will be called only once when the drag begins. This provides you + /// with the [ThumbDragDetails]. + /// + /// This method is useful if you are planning to do something like add a time + /// label and/or video preview over the thumb and you need to do some + /// initialization. + /// + /// Use [onSeek] if you only want to seek to a new audio position when the + /// drag event has finished. + final ThumbDragStartCallback? onDragStart; + + /// A callback when the user is moving the thumb. + /// + /// This will be called repeatedly as the thumb position changes. This + /// provides you with the [ThumbDragDetails], which notify you of the global + /// and local positions of the drag event as well as the current thumb + /// duration. The current thumb duration will not go beyond [total] or less + /// that `Duration.zero` so you can use this information to clamp the drag + /// position values. + /// + /// This method is useful if you are planning to do something like add a time + /// label and/or video preview over the thumb and need to update the position + /// to stay in sync with the thumb position. + /// + /// Use [onSeek] if you only want to seek to a new audio position when the + /// drag event has finished. + final ThumbDragUpdateCallback? onDragUpdate; + + /// A callback when the user is finished moving the thumb. + /// + /// This will be called only once when the drag ends. + /// + /// This method is useful if you are planning to do something like add a time + /// label and/or video preview over the thumb and you need to dispose of + /// something when the drag is finished. + /// + /// This method is called directly before [onSeek]. + final VoidCallback? onDragEnd; + + /// The vertical thickness of the progress bar. + final double barHeight; + + /// The color of the progress bar before playback has started. + /// + /// By default it is a transparent version of your theme's primary color. + final Color? baseBarColor; + + /// The color of the progress bar to the left of the current playing + /// [progress]. + /// + /// By default it is your theme's primary color. + final Color? progressBarColor; + + /// The color of the progress bar between the [progress] location and the + /// [buffered] location. + /// + /// By default it is a transparent version of your theme's primary color, + /// a shade darker than [baseBarColor]. + final Color? bufferedBarColor; + + /// The shape of the bar at the left and right ends. + /// + /// This affects the base bar for the total time, the current progress bar, + /// and the buffered progress bar. The default is [BarCapShape.round]. + final BarCapShape barCapShape; + + /// The radius of the circle for the moveable progress bar thumb. + final double thumbRadius; + + /// The color of the circle for the moveable progress bar thumb. + /// + /// By default it is your theme's primary color. + final Color? thumbColor; + + /// The color of the pressed-down effect of the moveable progress bar thumb. + /// + /// By default it is [thumbColor] with an alpha value of 80. + final Color? thumbGlowColor; + + /// The radius of the circle for the pressed-down effect of the moveable + /// progress bar thumb. + /// + /// By default it is 30. + final double thumbGlowRadius; + + /// Whether the thumb radius will before the start of the bar when at the + /// beginning or after the end of the bar when at the end. + /// + /// The default is `true` and this means that the thumb will be painted + /// outside of the bounds of the widget if there are no side labels. You can + /// wrap [ProgressBar] with a `Padding` widget if your layout needs to leave + /// some extra room for the thumb. + /// + /// When set to `false` the thumb will be clamped within the width of the + /// bar. This is nice for aligning the thumb with vertical labels at the start + /// and end of playback. However, because of the clamping, the thumb won't + /// move during audio/video playback when near the ends. Depending on the + /// size of the thumb and the length of the song, this usually only lasts + /// a few seconds. The progress label still indicates that playback + /// is happening during this time, though. + final bool thumbCanPaintOutsideBar; + + /// The location for the [progress] and [total] duration text labels. + /// + /// By default the labels appear under the progress bar but you can also + /// put them above, on the sides, or remove them altogether. + final TimeLabelLocation? timeLabelLocation; + + /// What to display for the time label on the right + /// + /// The right time label can show the total time or the remaining time as a + /// negative number. The default is [TimeLabelType.totalTime]. + final TimeLabelType? timeLabelType; + + /// The [TextStyle] used by the time labels. + /// + /// By default it is [TextTheme.bodyLarge]. + final TextStyle? timeLabelTextStyle; + + /// The extra space between the time labels and the progress bar. + /// + /// The default is 0.0. A positive number will move the labels further from + /// the progress bar and a negative number will move them closer. + final double timeLabelPadding; + + @override + RenderObject createRenderObject(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + final textStyle = timeLabelTextStyle ?? theme.textTheme.bodyLarge; + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + return _RenderProgressBar( + progress: progress, + total: total, + buffered: buffered ?? Duration.zero, + onSeek: onSeek, + onDragStart: onDragStart, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd, + barHeight: barHeight, + baseBarColor: baseBarColor ?? primaryColor.withOpacity(0.24), + progressBarColor: progressBarColor ?? primaryColor, + bufferedBarColor: bufferedBarColor ?? primaryColor.withOpacity(0.24), + barCapShape: barCapShape, + thumbRadius: thumbRadius, + thumbColor: thumbColor ?? primaryColor, + thumbGlowColor: + thumbGlowColor ?? (thumbColor ?? primaryColor).withAlpha(80), + thumbGlowRadius: thumbGlowRadius, + thumbCanPaintOutsideBar: thumbCanPaintOutsideBar, + timeLabelLocation: timeLabelLocation ?? TimeLabelLocation.below, + timeLabelType: timeLabelType ?? TimeLabelType.totalTime, + timeLabelTextStyle: textStyle, + timeLabelPadding: timeLabelPadding, + textScaleFactor: textScaleFactor, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderObject renderObject) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + final textStyle = timeLabelTextStyle ?? theme.textTheme.bodyLarge; + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + (renderObject as _RenderProgressBar) + ..progress = progress + ..total = total + ..buffered = buffered ?? Duration.zero + ..onSeek = onSeek + ..onDragStart = onDragStart + ..onDragUpdate = onDragUpdate + ..onDragEnd = onDragEnd + ..barHeight = barHeight + ..baseBarColor = baseBarColor ?? primaryColor.withOpacity(0.24) + ..progressBarColor = progressBarColor ?? primaryColor + ..bufferedBarColor = bufferedBarColor ?? primaryColor.withOpacity(0.24) + ..barCapShape = barCapShape + ..thumbRadius = thumbRadius + ..thumbColor = thumbColor ?? primaryColor + ..thumbGlowColor = + thumbGlowColor ?? (thumbColor ?? primaryColor).withAlpha(80) + ..thumbGlowRadius = thumbGlowRadius + ..thumbCanPaintOutsideBar = thumbCanPaintOutsideBar + ..timeLabelLocation = timeLabelLocation ?? TimeLabelLocation.below + ..timeLabelType = timeLabelType ?? TimeLabelType.totalTime + ..timeLabelTextStyle = textStyle + ..timeLabelPadding = timeLabelPadding + ..textScaleFactor = textScaleFactor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('progress', progress.toString())); + properties.add(StringProperty('total', total.toString())); + properties.add(StringProperty('buffered', buffered.toString())); + properties.add(ObjectFlagProperty>('onSeek', onSeek, + ifNull: 'unimplemented')); + properties.add(ObjectFlagProperty( + 'onDragStart', onDragStart, + ifNull: 'unimplemented')); + properties.add(ObjectFlagProperty( + 'onDragUpdate', onDragUpdate, + ifNull: 'unimplemented')); + properties.add(ObjectFlagProperty('onDragEnd', onDragEnd, + ifNull: 'unimplemented')); + properties.add(DoubleProperty('barHeight', barHeight)); + properties.add(ColorProperty('baseBarColor', baseBarColor)); + properties.add(ColorProperty('progressBarColor', progressBarColor)); + properties.add(ColorProperty('bufferedBarColor', bufferedBarColor)); + properties.add(StringProperty('barCapShape', barCapShape.toString())); + properties.add(DoubleProperty('thumbRadius', thumbRadius)); + properties.add(ColorProperty('thumbColor', thumbColor)); + properties.add(ColorProperty('thumbGlowColor', thumbGlowColor)); + properties.add(DoubleProperty('thumbGlowRadius', thumbGlowRadius)); + properties.add( + FlagProperty( + 'thumbCanPaintOutsideBar', + value: thumbCanPaintOutsideBar, + ifTrue: 'true', + ifFalse: 'false', + showName: true, + ), + ); + properties + .add(StringProperty('timeLabelLocation', timeLabelLocation.toString())); + properties.add(StringProperty('timeLabelType', timeLabelType.toString())); + properties + .add(DiagnosticsProperty('timeLabelTextStyle', timeLabelTextStyle)); + properties.add(DoubleProperty('timeLabelPadding', timeLabelPadding)); + } +} + +/// The callback signature for when the thumb begins a horizontal drag. +typedef ThumbDragStartCallback = void Function(ThumbDragDetails details); + +/// The callback signature for when the thumb is moving on horizontally and has +/// new data. +typedef ThumbDragUpdateCallback = void Function(ThumbDragDetails details); + +/// Data to pass back on drag callback events +class ThumbDragDetails { + const ThumbDragDetails({ + this.timeStamp = Duration.zero, + this.globalPosition = Offset.zero, + this.localPosition = Offset.zero, + }); + + /// The duration position of the thumb on the progress bar + final Duration timeStamp; + + /// The global position of the drag event moving the thumb on the progress bar. + final Offset globalPosition; + + /// The local position of the drag event moving the thumb on the progress bar. + final Offset localPosition; + + @override + String toString() => '${objectRuntimeType(this, 'ThumbDragDetails')}(' + 'time: $timeStamp, ' + 'global: $globalPosition, ' + 'local: $localPosition)'; +} + +// Handles all gestures so that it will always win a the gesture arena. +// Without doing this, if you used this widget in a swipable tab layout, +// you would cause a swipe rather than a drag when trying to move the thumb. +class _EagerHorizontalDragGestureRecognizer + extends HorizontalDragGestureRecognizer { + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + resolve(GestureDisposition.accepted); + } + + @override + String get debugDescription => '_EagerHorizontalDragGestureRecognizer'; +} + +class _RenderProgressBar extends RenderBox { + _RenderProgressBar({ + required Duration progress, + required Duration total, + required Duration buffered, + ValueChanged? onSeek, + ThumbDragStartCallback? onDragStart, + ThumbDragUpdateCallback? onDragUpdate, + VoidCallback? onDragEnd, + required double barHeight, + required Color baseBarColor, + required Color progressBarColor, + required Color bufferedBarColor, + required BarCapShape barCapShape, + double thumbRadius = 20.0, + required Color thumbColor, + required Color thumbGlowColor, + double thumbGlowRadius = 30.0, + bool thumbCanPaintOutsideBar = true, + required TimeLabelLocation timeLabelLocation, + required TimeLabelType timeLabelType, + TextStyle? timeLabelTextStyle, + double timeLabelPadding = 0.0, + double textScaleFactor = 1.0, + }) : _total = total, + _buffered = buffered, + _onSeek = onSeek, + _onDragStartUserCallback = onDragStart, + _onDragUpdateUserCallback = onDragUpdate, + _onDragEndUserCallback = onDragEnd, + _barHeight = barHeight, + _baseBarColor = baseBarColor, + _progressBarColor = progressBarColor, + _bufferedBarColor = bufferedBarColor, + _barCapShape = barCapShape, + _thumbRadius = thumbRadius, + _thumbColor = thumbColor, + _thumbGlowColor = thumbGlowColor, + _thumbGlowRadius = thumbGlowRadius, + _thumbCanPaintOutsideBar = thumbCanPaintOutsideBar, + _timeLabelLocation = timeLabelLocation, + _timeLabelType = timeLabelType, + _timeLabelTextStyle = timeLabelTextStyle, + _timeLabelPadding = timeLabelPadding, + _textScaleFactor = textScaleFactor { + _drag = _EagerHorizontalDragGestureRecognizer() + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _finishDrag; + if (!_userIsDraggingThumb) { + _progress = progress; + _thumbValue = _proportionOfTotal(_progress); + } + } + + // This is the gesture recognizer used to move the thumb. + _EagerHorizontalDragGestureRecognizer? _drag; + + // This is a value between 0.0 and 1.0 used to indicate the position on + // the bar. + late double _thumbValue; + + // The thumb can move for two reasons. One is that the [progress] changed. + // The other is that the user is dragging the thumb. This variable keeps + // track of that so that while the user is dragging the thumb at the same + // time as a [progress] update there won't be a conflict. + bool _userIsDraggingThumb = false; + + // This padding is always used between the time labels and the progress bar + // when the time labels are on the sides. Any user defined [timeLabelPadding] + // is in addition to this. + double get _defaultSidePadding { + const minPadding = 5.0; + return (_thumbCanPaintOutsideBar) ? thumbRadius + minPadding : minPadding; + } + + void _onDragStart(DragStartDetails details) { + _userIsDraggingThumb = true; + _updateThumbPosition(details.localPosition); + onDragStart?.call(ThumbDragDetails( + timeStamp: _currentThumbDuration(), + globalPosition: details.globalPosition, + localPosition: details.localPosition, + )); + } + + void _onDragUpdate(DragUpdateDetails details) { + _updateThumbPosition(details.localPosition); + onDragUpdate?.call(ThumbDragDetails( + timeStamp: _currentThumbDuration(), + globalPosition: details.globalPosition, + localPosition: details.localPosition, + )); + } + + void _onDragEnd(DragEndDetails details) { + onDragEnd?.call(); + onSeek?.call(_currentThumbDuration()); + _finishDrag(); + } + + void _finishDrag() { + _userIsDraggingThumb = false; + markNeedsPaint(); + } + + Duration _currentThumbDuration() { + final thumbMilliseconds = _thumbValue * total.inMilliseconds; + return Duration(milliseconds: thumbMilliseconds.round()); + } + + // This needs to stay in sync with the layout. This could be a potential + // source of bugs if there is a layout change but we forget to update this. + // It might be a good idea to redesign the architecture so that there is + // only one place to make changes. + void _updateThumbPosition(Offset localPosition) { + final dx = localPosition.dx; + double lengthBefore = 0.0; + double lengthAfter = 0.0; + if (_timeLabelLocation == TimeLabelLocation.sides) { + lengthBefore = + _leftLabelSize.width + _defaultSidePadding + _timeLabelPadding; + lengthAfter = + _rightLabelSize.width + _defaultSidePadding + _timeLabelPadding; + } + // The paint used to draw the bar line draws half of the cap before the + // start of the line (and after the end of the line). The cap radius is + // equal to half of the line width, which in this case is the bar height. + final barCapRadius = _barHeight / 2; + double barStart = lengthBefore + barCapRadius; + double barEnd = size.width - lengthAfter - barCapRadius; + final barWidth = barEnd - barStart; + final position = (dx - barStart).clamp(0.0, barWidth); + _thumbValue = (position / barWidth); + _progress = _currentThumbDuration(); + markNeedsPaint(); + } + + /// The play location of the media. + /// + /// This is used to update the thumb value and the left time label. + Duration get progress => _progress; + Duration _progress = Duration.zero; + set progress(Duration value) { + final clamp = _clampDuration(value); + if (_progress == clamp) { + return; + } + if (_labelLengthDifferent(_progress, clamp)) { + _clearLabelCache(); + } + if (!_userIsDraggingThumb) { + _progress = clamp; + _thumbValue = _proportionOfTotal(clamp); + } + markNeedsPaint(); + } + + bool _labelLengthDifferent(Duration first, Duration second) { + return (first.inMinutes < 10 && second.inMinutes >= 10) || + (first.inMinutes >= 10 && second.inMinutes < 10) || + (first.inHours == 0 && second.inHours != 0) || + (first.inHours != 0 && second.inHours == 0) || + (first.inHours < 10 && second.inHours >= 10) || + (first.inHours >= 10 && second.inHours < 10); + } + + TextPainter? _cachedLeftLabel; + Size get _leftLabelSize { + _cachedLeftLabel ??= _leftTimeLabel(); + return _cachedLeftLabel!.size; + } + + TextPainter? _cachedRightLabel; + Size get _rightLabelSize { + _cachedRightLabel ??= _rightTimeLabel(); + return _cachedRightLabel!.size; + } + + void _clearLabelCache() { + _cachedLeftLabel = null; + _cachedRightLabel = null; + } + + TextPainter _leftTimeLabel() { + final text = _getTimeString(progress); + return _layoutText(text); + } + + TextPainter _rightTimeLabel() { + switch (timeLabelType) { + case TimeLabelType.totalTime: + final text = _getTimeString(total); + return _layoutText(text); + case TimeLabelType.remainingTime: + final remaining = total - progress; + final text = '-${_getTimeString(remaining)}'; + return _layoutText(text); + } + } + + TextPainter _layoutText(String text) { + TextPainter textPainter = TextPainter( + text: TextSpan(text: text, style: _timeLabelTextStyle), + textDirection: TextDirection.ltr, + textScaleFactor: textScaleFactor, + ); + textPainter.layout(minWidth: 0, maxWidth: double.infinity); + return textPainter; + } + + /// The total time length of the media. + Duration get total => _total; + Duration _total; + set total(Duration value) { + final clamp = (value.isNegative) ? Duration.zero : value; + if (_total == clamp) { + return; + } + if (_labelLengthDifferent(_total, clamp)) { + _clearLabelCache(); + } + _total = clamp; + if (!_userIsDraggingThumb) { + _thumbValue = _proportionOfTotal(progress); + } + markNeedsPaint(); + } + + /// The buffered length of the media when streaming. + Duration get buffered => _buffered; + Duration _buffered; + set buffered(Duration value) { + final clamp = _clampDuration(value); + if (_buffered == clamp) { + return; + } + _buffered = clamp; + markNeedsPaint(); + } + + Duration _clampDuration(Duration value) { + if (value.isNegative) return Duration.zero; + if (value.compareTo(_total) > 0) return _total; + return value; + } + + /// A callback for the audio duration position to where the thumb was moved. + ValueChanged? get onSeek => _onSeek; + ValueChanged? _onSeek; + set onSeek(ValueChanged? value) { + if (value == _onSeek) { + return; + } + _onSeek = value; + } + + /// A callback when the thumb starts being dragged. + ThumbDragStartCallback? get onDragStart => _onDragStartUserCallback; + ThumbDragStartCallback? _onDragStartUserCallback; + set onDragStart(ThumbDragStartCallback? value) { + if (value == _onDragStartUserCallback) { + return; + } + _onDragStartUserCallback = value; + } + + /// A callback when the thumb is being dragged. + ThumbDragUpdateCallback? get onDragUpdate => _onDragUpdateUserCallback; + ThumbDragUpdateCallback? _onDragUpdateUserCallback; + set onDragUpdate(ThumbDragUpdateCallback? value) { + if (value == _onDragUpdateUserCallback) { + return; + } + _onDragUpdateUserCallback = value; + } + + /// A callback when the thumb drag is finished. + VoidCallback? get onDragEnd => _onDragEndUserCallback; + VoidCallback? _onDragEndUserCallback; + set onDragEnd(VoidCallback? value) { + if (value == _onDragEndUserCallback) { + return; + } + _onDragEndUserCallback = value; + } + + /// The vertical thickness of the bar that the thumb moves along. + double get barHeight => _barHeight; + double _barHeight; + set barHeight(double value) { + if (_barHeight == value) return; + _barHeight = value; + markNeedsPaint(); + } + + /// The color of the progress bar before any playing or buffering. + Color get baseBarColor => _baseBarColor; + Color _baseBarColor; + set baseBarColor(Color value) { + if (_baseBarColor == value) return; + _baseBarColor = value; + markNeedsPaint(); + } + + /// The color of the played portion of the progress bar. + Color get progressBarColor => _progressBarColor; + Color _progressBarColor; + set progressBarColor(Color value) { + if (_progressBarColor == value) return; + _progressBarColor = value; + markNeedsPaint(); + } + + /// The color of the visible buffered portion of the progress bar. + Color get bufferedBarColor => _bufferedBarColor; + Color _bufferedBarColor; + set bufferedBarColor(Color value) { + if (_bufferedBarColor == value) return; + _bufferedBarColor = value; + markNeedsPaint(); + } + + BarCapShape get barCapShape => _barCapShape; + BarCapShape _barCapShape; + set barCapShape(BarCapShape value) { + if (_barCapShape == value) return; + _barCapShape = value; + markNeedsPaint(); + } + + /// The color of the moveable thumb. + Color get thumbColor => _thumbColor; + Color _thumbColor; + set thumbColor(Color value) { + if (_thumbColor == value) return; + _thumbColor = value; + markNeedsPaint(); + } + + /// The length of the radius for the circular thumb. + double get thumbRadius => _thumbRadius; + double _thumbRadius; + set thumbRadius(double value) { + if (_thumbRadius == value) return; + _thumbRadius = value; + markNeedsLayout(); + } + + /// The color of the pressed-down effect of the moveable thumb. + Color get thumbGlowColor => _thumbGlowColor; + Color _thumbGlowColor; + set thumbGlowColor(Color value) { + if (_thumbGlowColor == value) return; + _thumbGlowColor = value; + if (_userIsDraggingThumb) markNeedsPaint(); + } + + /// The length of the radius of the pressed-down effect of the moveable thumb. + double get thumbGlowRadius => _thumbGlowRadius; + double _thumbGlowRadius; + set thumbGlowRadius(double value) { + if (_thumbGlowRadius == value) return; + _thumbGlowRadius = value; + markNeedsLayout(); + } + + /// Whether the thumb will paint before the start or after the end of the bar. + bool get thumbCanPaintOutsideBar => _thumbCanPaintOutsideBar; + bool _thumbCanPaintOutsideBar; + set thumbCanPaintOutsideBar(bool value) { + if (_thumbCanPaintOutsideBar == value) return; + _thumbCanPaintOutsideBar = value; + markNeedsPaint(); + } + + /// The position of the duration text labels for the progress and total time. + TimeLabelLocation get timeLabelLocation => _timeLabelLocation; + TimeLabelLocation _timeLabelLocation; + set timeLabelLocation(TimeLabelLocation value) { + if (_timeLabelLocation == value) return; + _timeLabelLocation = value; + markNeedsLayout(); + } + + /// What to display for the time label on the right + /// + /// The right time label can show the total time or the remaining time as a + /// negative number. The default is [TimeLabelType.totalTime]. + TimeLabelType get timeLabelType => _timeLabelType; + TimeLabelType _timeLabelType; + set timeLabelType(TimeLabelType value) { + if (_timeLabelType == value) return; + _timeLabelType = value; + _clearLabelCache(); + markNeedsLayout(); + } + + /// The text style for the duration text labels. By default this style is + /// taken from the theme's [textStyle.bodyText1]. + TextStyle? get timeLabelTextStyle => _timeLabelTextStyle; + TextStyle? _timeLabelTextStyle; + set timeLabelTextStyle(TextStyle? value) { + if (_timeLabelTextStyle == value) return; + _timeLabelTextStyle = value; + _clearLabelCache(); + markNeedsLayout(); + } + + /// The length of the radius for the circular thumb. + double get timeLabelPadding => _timeLabelPadding; + double _timeLabelPadding; + set timeLabelPadding(double value) { + if (_timeLabelPadding == value) return; + _timeLabelPadding = value; + markNeedsLayout(); + } + + /// The text scale factor for the `progress` and `total` text labels. + /// By default the value is 1.0. + double get textScaleFactor => _textScaleFactor; + double _textScaleFactor; + set textScaleFactor(double value) { + if (_textScaleFactor == value) return; + _textScaleFactor = value; + _clearLabelCache(); + markNeedsLayout(); + } + + // The smallest that this widget would ever want to be. + static const _minDesiredWidth = 100.0; + + @override + double computeMinIntrinsicWidth(double height) => _minDesiredWidth; + + @override + double computeMaxIntrinsicWidth(double height) => _minDesiredWidth; + + @override + double computeMinIntrinsicHeight(double width) => _calculateDesiredHeight(); + + @override + double computeMaxIntrinsicHeight(double width) => _calculateDesiredHeight(); + + @override + bool hitTestSelf(Offset position) => true; + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent) { + _drag?.addPointer(event); + } + } + + @override + void performLayout() { + size = computeDryLayout(constraints); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final desiredWidth = constraints.maxWidth; + final desiredHeight = _calculateDesiredHeight(); + final desiredSize = Size(desiredWidth, desiredHeight); + return constraints.constrain(desiredSize); + } + + // When changing these remember to keep the gesture recognizer for the + // thumb in sync. + double _calculateDesiredHeight() { + switch (_timeLabelLocation) { + case TimeLabelLocation.below: + case TimeLabelLocation.above: + return _heightWhenLabelsAboveOrBelow(); + case TimeLabelLocation.sides: + return _heightWhenLabelsOnSides(); + default: + return _heightWhenNoLabels(); + } + } + + double _heightWhenLabelsAboveOrBelow() { + return _heightWhenNoLabels() + _textHeight() + _timeLabelPadding; + } + + double _heightWhenLabelsOnSides() { + return max(_heightWhenNoLabels(), _textHeight()); + } + + double _heightWhenNoLabels() { + return max(2 * _thumbRadius, _barHeight); + } + + double _textHeight() { + return _leftLabelSize.height; + } + + @override + bool get isRepaintBoundary => true; + + @override + void paint(PaintingContext context, Offset offset) { + final canvas = context.canvas; + canvas.save(); + canvas.translate(offset.dx, offset.dy); + + switch (_timeLabelLocation) { + case TimeLabelLocation.above: + case TimeLabelLocation.below: + _drawProgressBarWithLabelsAboveOrBelow(canvas); + break; + case TimeLabelLocation.sides: + _drawProgressBarWithLabelsOnSides(canvas); + break; + default: + _drawProgressBarWithoutLabels(canvas); + } + + canvas.restore(); + } + + /// Draw the progress bar and labels vertically aligned: + /// + /// | -------O---------------- | + /// | 01:23 05:00 | + /// + /// Or like this: + /// + /// | 01:23 05:00 | + /// | -------O---------------- | + void _drawProgressBarWithLabelsAboveOrBelow(Canvas canvas) { + // calculate sizes + final barWidth = size.width; + final barHeight = _heightWhenNoLabels(); + + // whether to paint the labels below the progress bar or above it + final isLabelBelow = _timeLabelLocation == TimeLabelLocation.below; + + // current time label + final labelDy = (isLabelBelow) ? barHeight + _timeLabelPadding : 0.0; + final leftLabelOffset = Offset(0, labelDy); + _leftTimeLabel().paint(canvas, leftLabelOffset); + + // total or remaining time label + final rightLabelDx = size.width - _rightLabelSize.width; + final rightLabelOffset = Offset(rightLabelDx, labelDy); + _rightTimeLabel().paint(canvas, rightLabelOffset); + + // progress bar + final barDy = + (isLabelBelow) ? 0.0 : _leftLabelSize.height + _timeLabelPadding; + _drawProgressBar(canvas, Offset(0, barDy), Size(barWidth, barHeight)); + } + + /// Draw the progress bar and labels horizontally aligned: + /// + /// | 01:23 -------O---------------- 05:00 | + /// + void _drawProgressBarWithLabelsOnSides(Canvas canvas) { + // left time label + final leftLabelSize = _leftLabelSize; + final verticalOffset = size.height / 2 - leftLabelSize.height / 2; + final leftLabelOffset = Offset(0, verticalOffset); + _leftTimeLabel().paint(canvas, leftLabelOffset); + + // right time label + final rightLabelSize = _rightLabelSize; + final rightLabelWidth = rightLabelSize.width; + final totalLabelDx = size.width - rightLabelWidth; + final totalLabelOffset = Offset(totalLabelDx, verticalOffset); + _rightTimeLabel().paint(canvas, totalLabelOffset); + + // progress bar + final leftLabelWidth = leftLabelSize.width; + final barHeight = _heightWhenNoLabels(); + final barWidth = size.width - + 2 * _defaultSidePadding - + 2 * _timeLabelPadding - + leftLabelWidth - + rightLabelWidth; + final barDy = size.height / 2 - barHeight / 2; + final barDx = leftLabelWidth + _defaultSidePadding + _timeLabelPadding; + _drawProgressBar(canvas, Offset(barDx, barDy), Size(barWidth, barHeight)); + } + + /// Draw the progress bar without labels like this: + /// + /// | -------O---------------- | + /// + void _drawProgressBarWithoutLabels(Canvas canvas) { + final barWidth = size.width; + final barHeight = 2 * _thumbRadius; + _drawProgressBar(canvas, Offset.zero, Size(barWidth, barHeight)); + } + + void _drawProgressBar(Canvas canvas, Offset offset, Size localSize) { + canvas.save(); + canvas.translate(offset.dx, offset.dy); + _drawBaseBar(canvas, localSize); + _drawBufferedBar(canvas, localSize); + _drawCurrentProgressBar(canvas, localSize); + _drawThumb(canvas, localSize); + canvas.restore(); + } + + void _drawBaseBar(Canvas canvas, Size localSize) { + _drawBar( + canvas: canvas, + availableSize: localSize, + widthProportion: 1.0, + color: baseBarColor, + ); + } + + void _drawBufferedBar(Canvas canvas, Size localSize) { + _drawBar( + canvas: canvas, + availableSize: localSize, + widthProportion: _proportionOfTotal(_buffered), + color: bufferedBarColor, + ); + } + + void _drawCurrentProgressBar(Canvas canvas, Size localSize) { + _drawBar( + canvas: canvas, + availableSize: localSize, + widthProportion: _proportionOfTotal(_progress), + color: progressBarColor, + ); + } + + void _drawBar( + {required Canvas canvas, + required Size availableSize, + required double widthProportion, + required Color color}) { + final strokeCap = (_barCapShape == BarCapShape.round) + ? StrokeCap.round + : StrokeCap.square; + final baseBarPaint = Paint() + ..color = color + ..strokeCap = strokeCap + ..strokeWidth = _barHeight; + final capRadius = _barHeight / 2; + final adjustedWidth = availableSize.width - barHeight; + final dx = widthProportion * adjustedWidth + capRadius; + final startPoint = Offset(capRadius, availableSize.height / 2); + var endPoint = Offset(dx, availableSize.height / 2); + canvas.drawLine(startPoint, endPoint, baseBarPaint); + } + + void _drawThumb(Canvas canvas, Size localSize) { + final thumbPaint = Paint()..color = thumbColor; + final barCapRadius = _barHeight / 2; + final availableWidth = localSize.width - _barHeight; + var thumbDx = _thumbValue * availableWidth + barCapRadius; + if (!_thumbCanPaintOutsideBar) { + thumbDx = thumbDx.clamp(_thumbRadius, localSize.width - _thumbRadius); + } + final center = Offset(thumbDx, localSize.height / 2); + if (_userIsDraggingThumb) { + final thumbGlowPaint = Paint()..color = thumbGlowColor; + canvas.drawCircle(center, thumbGlowRadius, thumbGlowPaint); + } + canvas.drawCircle(center, thumbRadius, thumbPaint); + } + + double _proportionOfTotal(Duration duration) { + if (total.inMilliseconds == 0) { + return 0.0; + } + return duration.inMilliseconds / total.inMilliseconds; + } + + String _getTimeString(Duration time) { + final minutes = + time.inMinutes.remainder(Duration.minutesPerHour).toString(); + final seconds = time.inSeconds + .remainder(Duration.secondsPerMinute) + .toString() + .padLeft(2, '0'); + return time.inHours > 0 + ? "${time.inHours}:${minutes.padLeft(2, "0")}:$seconds" + : "$minutes:$seconds"; + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + + // description + config.textDirection = TextDirection.ltr; + config.label = '进度条';//'Progress bar'; + config.value = '${(_thumbValue * 100).round()}%'; + + // increase action + config.onIncrease = increaseAction; + final increased = _thumbValue + _semanticActionUnit; + config.increasedValue = '${((increased).clamp(0.0, 1.0) * 100).round()}%'; + + // decrease action + config.onDecrease = decreaseAction; + final decreased = _thumbValue - _semanticActionUnit; + config.decreasedValue = '${((decreased).clamp(0.0, 1.0) * 100).round()}%'; + } + + // This is how much to move the thumb if the move is triggered by a + // semantic action rather than a touch event. + static const double _semanticActionUnit = 0.05; + + void increaseAction() { + final newValue = _thumbValue + _semanticActionUnit; + _thumbValue = (newValue).clamp(0.0, 1.0); + markNeedsPaint(); + markNeedsSemanticsUpdate(); + onSeek?.call(_currentThumbDuration()); + } + + void decreaseAction() { + final newValue = _thumbValue - _semanticActionUnit; + _thumbValue = (newValue).clamp(0.0, 1.0); + markNeedsPaint(); + markNeedsSemanticsUpdate(); + onSeek?.call(_currentThumbDuration()); + } +}