import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import '../../services/music_player_background_task.dart'; import '../../services/progress_state_stream.dart'; import '../print_duration.dart'; class ProgressSlider extends StatefulWidget { const ProgressSlider({ Key? key, this.allowSeeking = true, this.showBuffer = true, this.showDuration = true, this.showPlaceholder = true, }) : super(key: key); final bool allowSeeking; final bool showBuffer; final bool showDuration; final bool showPlaceholder; @override State createState() => _ProgressSliderState(); } class _ProgressSliderState extends State { /// Value used to hold the slider's value when dragging. double? _dragValue; late SliderThemeData _sliderThemeData; final _audioHandler = GetIt.instance(); @override void didChangeDependencies() { super.didChangeDependencies(); _sliderThemeData = SliderTheme.of(context).copyWith( trackHeight: 4.0, inactiveTrackColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), ); } @override Widget build(BuildContext context) { // The slider always needs to be LTR, so we use Directionality to save // putting TextDirection.ltr all over the place return Directionality( textDirection: TextDirection.ltr, // The slider can refresh up to 60 times per second, so we wrap it in a // RepaintBoundary to avoid more areas being repainted than necessary child: RepaintBoundary( child: StreamBuilder( stream: progressStateStream, builder: (context, snapshot) { if (snapshot.data?.mediaItem == null) { // If nothing is playing or the AudioService isn't connected, return a // greyed out slider with some fake numbers. We also do this if // currentPosition is null, which sometimes happens when the app is // closed and reopened. return widget.showPlaceholder ? Column( children: [ SliderTheme( data: _sliderThemeData.copyWith( trackShape: CustomTrackShape(), ), child: const Slider( value: 0, max: 1, onChanged: null, ), ), if (widget.showDuration) Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "00:00", style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: Theme.of(context) .textTheme .bodySmall ?.color), ), Text( "00:00", style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: Theme.of(context) .textTheme .bodySmall ?.color), ), ], ), ], ) : const SizedBox.shrink(); } else if (snapshot.hasData) { return Column( mainAxisSize: MainAxisSize.min, children: [ // Slider displaying playback and buffering progress. SliderTheme( data: widget.allowSeeking ? _sliderThemeData.copyWith( trackShape: CustomTrackShape(), ) : _sliderThemeData.copyWith( thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 0), // gets rid of both horizontal and vertical padding overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), trackShape: const RectangularSliderTrackShape(), ), child: Slider( min: 0.0, max: snapshot.data!.mediaItem?.duration == null ? snapshot.data!.playbackState.bufferedPosition .inMicroseconds .toDouble() : snapshot.data!.mediaItem!.duration!.inMicroseconds .toDouble(), value: (_dragValue ?? snapshot.data!.position.inMicroseconds) .clamp( 0, snapshot.data!.mediaItem!.duration!.inMicroseconds .toDouble()) .toDouble(), secondaryTrackValue: widget.showBuffer && snapshot.data!.mediaItem ?.extras?["downloadedSongJson"] == null ? snapshot.data!.playbackState.bufferedPosition .inMicroseconds .clamp( 0.0, snapshot.data!.mediaItem!.duration == null ? snapshot.data!.playbackState .bufferedPosition.inMicroseconds : snapshot.data!.mediaItem!.duration! .inMicroseconds, ) .toDouble() : 0, onChanged: widget.allowSeeking ? (newValue) async { // We don't actually tell audio_service to seek here // because it would get flooded with seek requests setState(() { _dragValue = newValue; }); } : (_) {}, onChangeStart: widget.allowSeeking ? (value) { setState(() { _dragValue = value; }); } : (_) {}, onChangeEnd: widget.allowSeeking ? (newValue) async { // Seek to the new position await _audioHandler.seek( Duration(microseconds: newValue.toInt())); // Clear drag value so that the slider uses the play // duration again. setState(() { _dragValue = null; }); } : (_) {}, ), ), if (widget.showDuration) Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( printDuration( Duration( microseconds: _dragValue?.toInt() ?? snapshot.data!.position.inMicroseconds), ), style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: Theme.of(context) .textTheme .bodySmall ?.color), ), Text( printDuration(snapshot.data!.mediaItem?.duration), style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: Theme.of(context) .textTheme .bodySmall ?.color), ), ], ), ], ); } else { return const Text( "Snapshot doesn't have data and MediaItem isn't null and AudioService is connected?"); } }, ), ), ); } } class PositionData { final Duration position; final Duration bufferedPosition; PositionData(this.position, this.bufferedPosition); } /// Track shape used to remove horizontal padding. /// https://github.com/flutter/flutter/issues/37057 class CustomTrackShape extends RoundedRectSliderTrackShape { @override Rect getPreferredRect({ required RenderBox parentBox, Offset offset = Offset.zero, required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, }) { final double trackHeight = sliderTheme.trackHeight!; final double trackLeft = offset.dx; final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; final double trackWidth = parentBox.size.width; return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); } /// Disable additionalActiveTrackHeight @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 isDiscrete = false, bool isEnabled = false, double additionalActiveTrackHeight = 0, }) { super.paint( context, offset, parentBox: parentBox, sliderTheme: sliderTheme, enableAnimation: enableAnimation, textDirection: textDirection, thumbCenter: thumbCenter, secondaryOffset: secondaryOffset, isDiscrete: isDiscrete, isEnabled: isEnabled, additionalActiveTrackHeight: additionalActiveTrackHeight, ); } }