import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:freezer/api/player/player_helper.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:rxdart/rxdart.dart'; import '../api/player/audio_handler.dart'; import 'cached_image.dart'; class PlayerBar extends StatelessWidget { final VoidCallback? onTap; final bool shouldHaveHero; final Color? backgroundColor; final FocusNode? focusNode; const PlayerBar({ super.key, this.onTap, this.shouldHaveHero = true, this.backgroundColor, this.focusNode, }); final double iconSize = 28; double parsePosition(Duration position) { if (audioHandler.mediaItem.value == null) return 0.0; if (audioHandler.mediaItem.value!.duration!.inSeconds == 0) { return 0.0; //Division by 0 } return position.inSeconds / audioHandler.mediaItem.value!.duration!.inSeconds; } Color? get _backgroundColor => backgroundColor; @override Widget build(BuildContext context) { return SizedBox( height: 68.0, child: Column(mainAxisSize: MainAxisSize.max, children: [ Expanded( child: StreamBuilder( stream: audioHandler.mediaItem, initialData: audioHandler.mediaItem.valueOrNull, builder: (context, snapshot) { if (snapshot.data == null) { // lazy way to prevent dragging up return GestureDetector( behavior: HitTestBehavior.opaque, onVerticalDragEnd: (_) {}, onVerticalDragUpdate: (_) {}, child: Material( child: ListTile( dense: true, visualDensity: VisualDensity.standard, contentPadding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 6.0), leading: Image.asset( 'assets/cover_thumb.jpg', width: 48.0, height: 48.0, ), title: Text('Nothing is currently playing'.i18n), ), ), ); } final currentMediaItem = snapshot.data!; final image = CachedImage( rounded: true, width: 50, height: 50, url: currentMediaItem.extras!['thumb'] ?? currentMediaItem.artUri.toString(), ); final leadingWidget = shouldHaveHero ? Hero(tag: currentMediaItem.id, child: image) : image; return Material( type: MaterialType.transparency, child: ListTile( dense: true, tileColor: _backgroundColor, focusNode: focusNode, visualDensity: VisualDensity.standard, contentPadding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 0.0), onTap: onTap, leading: AnimatedSwitcher( key: const ValueKey('player_bar_art_switcher'), duration: const Duration(milliseconds: 250), child: leadingWidget), title: Text( currentMediaItem.displayTitle!, overflow: TextOverflow.clip, maxLines: 1, ), subtitle: Text( currentMediaItem.displaySubtitle ?? '', overflow: TextOverflow.clip, maxLines: 1, ), trailing: IconTheme( data: IconThemeData( color: settings.isDark ? Colors.white : Colors.grey[600]), child: Row( mainAxisSize: MainAxisSize.min, children: [ PrevNextButton( iconSize, prev: true, ), PlayPauseButton(iconSize), PrevNextButton(iconSize) ], ), ))); }), ), SizedBox( height: 3.0, child: StreamBuilder( stream: AudioService.position, builder: (context, snapshot) { return LinearProgressIndicator( value: parsePosition(snapshot.data ?? Duration.zero), ); }), ), ]), ); } } class PrevNextButton extends StatelessWidget { final double size; final bool prev; final bool hidePrev; const PrevNextButton(this.size, {super.key, this.prev = false, this.hidePrev = false}); @override Widget build(BuildContext context) { // Listens to both mediaItem updates (a.k.a. when the song changes) // and queue updates (so for example, in SmartTrackLists, when the songs are fetched, it's updated with the new items) return StreamBuilder( stream: Rx.combineLatest2, bool>( audioHandler.mediaItem, audioHandler.queue, (_, queue) => playerHelper.queueIndex < queue.length - 1), builder: (context, snapshot) { if (!prev) { return IconButton( splashRadius: size * 2.0, icon: Icon( Icons.skip_next, semanticLabel: "Play next".i18n, ), iconSize: size, onPressed: snapshot.data == true ? () => audioHandler.skipToNext() : null, ); } final canGoPrev = playerHelper.queueIndex > 0; if (!canGoPrev && hidePrev) return const SizedBox.shrink(); return IconButton( splashRadius: size * 2.0, icon: Icon( Icons.skip_previous, semanticLabel: "Play previous".i18n, ), iconSize: size, onPressed: canGoPrev ? () => audioHandler.skipToPrevious() : null, ); }, ); } } class PlayPauseButton extends StatefulWidget { final double size; final bool filled; final bool material3; final Color? iconColor; /// The color of the card if [filled] is true final Color? color; const PlayPauseButton( this.size, { super.key, this.filled = false, this.material3 = true, this.color, this.iconColor, }); @override State createState() => _PlayPauseButtonState(); } class _PlayPauseButtonState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _animation; late StreamSubscription _stateSubscription; late StreamSubscription _playingSubscription; late bool _canPlay = audioHandler.playbackState.value.processingState == AudioProcessingState.ready || audioHandler.playbackState.value.processingState == AudioProcessingState.idle; @override void initState() { _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 200)); _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); _stateSubscription = playerHelper.processingState.listen((processingState) { if (processingState == AudioProcessingState.ready || audioHandler.playbackState.value.processingState == AudioProcessingState.idle) { if (!_canPlay) setState(() => _canPlay = true); return; } setState(() => _canPlay = false); }); _playingSubscription = playerHelper.playing.listen((playing) { if (playing) { _controller.forward(); } else { _controller.reverse(); } }); super.initState(); } @override void dispose() { _stateSubscription.cancel(); _playingSubscription.cancel(); _controller.dispose(); super.dispose(); } void _playPause() { if (audioHandler.playbackState.value.playing) { audioHandler.pause(); } else { audioHandler.play(); } } @override Widget build(BuildContext context) { final Widget? child; if (_canPlay) { final icon = AnimatedIcon( icon: AnimatedIcons.play_pause, progress: _animation, semanticLabel: audioHandler.playbackState.value.playing ? 'Pause'.i18n : 'Play'.i18n, ); if (!widget.filled) { child = IconButton( color: widget.iconColor, icon: icon, iconSize: widget.size, onPressed: _playPause); } else { child = Material( type: MaterialType.transparency, child: InkWell( customBorder: widget.material3 ? null : const CircleBorder(), onTap: _playPause, child: IconTheme.merge( child: Center(child: icon), data: IconThemeData( size: widget.size / 2, color: widget.iconColor))), ); } } else { switch (audioHandler.playbackState.value.processingState) { //Stopped/Error case AudioProcessingState.error: child = null; break; //Loading, connecting, rewinding... default: child = const Center(child: CircularProgressIndicator()); break; } } if (widget.material3 && widget.filled) { return StreamBuilder( stream: playerHelper.playing, builder: (context, snapshot) { return AnimatedContainer( clipBehavior: Clip.antiAlias, width: widget.size * (snapshot.data == true ? 1.5 : 1), height: widget.size, duration: const Duration(milliseconds: 250), decoration: BoxDecoration( borderRadius: snapshot.data == true ? BorderRadius.circular(widget.size / 3) : BorderRadius.circular(widget.size * 0.5), color: widget.color), child: child); }); } if (widget.filled) { return AnimatedContainer( width: widget.size, height: widget.size, duration: const Duration(seconds: 1), decoration: BoxDecoration(shape: BoxShape.circle, color: widget.color), child: child); } return SizedBox.square(dimension: widget.size * 2, child: child); } }