import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:rxdart/rxdart.dart'; import '../api/player.dart'; import 'cached_image.dart'; import 'player_screen.dart'; class FancyScaffold extends StatefulWidget { final Widget bottomPanel; final double bottomPanelHeight; final Widget expandedPanel; final Widget bottomNavigationBar; final Widget body; const FancyScaffold({ required this.bottomPanel, required this.bottomPanelHeight, required this.expandedPanel, required this.bottomNavigationBar, required this.body, super.key, }); @override State createState() => _FancyScaffoldState(); } class _FancyScaffoldState extends State with TickerProviderStateMixin { // goes from 0 to 1 (double) // 0 = preview, 1 = expanded late final AnimationController _dragController; final _status = ValueNotifier(AnimationStatus.dismissed); @override void initState() { _dragController = AnimationController( vsync: this, duration: const Duration(milliseconds: 500)); _dragController.addStatusListener((status) => _status.value = status); super.initState(); } @override void dispose() { _dragController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final systemPadding = MediaQuery.of(context).viewPadding; final defaultBottomPadding = 80.0 + systemPadding.bottom; final screenHeight = MediaQuery.of(context).size.height; print('height: $screenHeight, padding: $systemPadding'); final _sizeAnimation = Tween( begin: widget.bottomPanelHeight / MediaQuery.of(context).size.height, end: 1.0, ).animate(_dragController); print('route: ' + (ModalRoute.of(context) == null ? 'no' : 'yes')); return WillPopScope( onWillPop: () { print('BACK PRESSED! ${_dragController.value}'); if (_status.value == AnimationStatus.completed || _status.value == AnimationStatus.reverse) { print('flinging without popping!'); _dragController.fling(velocity: -1.0); return Future.value(false); } print('fuck this, no'); return Future.value(true); }, child: Stack( children: [ Positioned.fill( child: Scaffold( body: widget.body, bottomNavigationBar: Column( children: [ SizedBox(height: widget.bottomPanelHeight), SizeTransition( axisAlignment: -1.0, sizeFactor: Tween(begin: 1.0, end: 0.0).animate(_sizeAnimation), child: widget.bottomNavigationBar, ), ], ), ), ), Positioned( bottom: 0, left: 0, right: 0, child: AnimatedBuilder( animation: _sizeAnimation, builder: (context, child) { final x = 1.0 - _sizeAnimation.value; return Padding( padding: EdgeInsets.only( bottom: (defaultBottomPadding /*+ 8.0*/) * x, //right: 8.0 * x, //left: 8.0 * x, ), child: child, ); }, child: ValueListenableBuilder( valueListenable: _status, builder: (context, state, child) { return GestureDetector( onVerticalDragEnd: _onVerticalDragEnd, onVerticalDragUpdate: _onVerticalDragUpdate, onTap: state == AnimationStatus.dismissed ? _onTap : null, child: child, ); }, child: SizeTransition( sizeFactor: _sizeAnimation, axisAlignment: -1.0, axis: Axis.vertical, child: SizedBox( height: screenHeight, width: MediaQuery.of(context).size.width, child: ValueListenableBuilder( valueListenable: _status, builder: (context, state, _) => Stack( children: [ if (state != AnimationStatus.dismissed) Positioned.fill( child: widget.expandedPanel, key: Key('player_screen'), ), if (state != AnimationStatus.completed) Positioned( top: 0, right: 0, left: 0, child: FadeTransition( opacity: Tween(begin: 1.0, end: 0.0) .animate(_dragController), child: SizedBox( height: widget.bottomPanelHeight, child: widget.bottomPanel), ), key: Key('player_bar'), ), ], ), ), )), ), ), ), ], ), ); } void _onTap() { _dragController.fling(); } void _onVerticalDragUpdate(DragUpdateDetails details) { _dragController.value -= details.delta.dy / MediaQuery.of(context).size.height; } void _onVerticalDragEnd(DragEndDetails details) { // snap widget to size // this should be also handled by drag velocity and not only with bare size. const double minFlingVelocity = 365.0; if (details.velocity.pixelsPerSecond.dy.abs() > minFlingVelocity) { _dragController.fling( velocity: -details.velocity.pixelsPerSecond.dy / MediaQuery.of(context).size.height); return; } _dragController.fling(velocity: _dragController.value > 0.5 ? 1.0 : -1.0); } } class PlayerBar extends StatefulWidget { final bool shouldHandleClicks; final bool shouldHaveHero; final Color? backgroundColor; const PlayerBar({ Key? key, this.shouldHandleClicks = true, this.shouldHaveHero = true, this.backgroundColor, }) : super(key: key); @override _PlayerBarState createState() => _PlayerBarState(); } class _PlayerBarState extends State { final double iconSize = 28; late StreamSubscription mediaItemSub; late bool _isNothingPlaying = audioHandler.mediaItem.value == null; final focusNode = FocusNode(); 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; } @override void initState() { mediaItemSub = audioHandler.mediaItem.listen((playingItem) { if ((playingItem == null && !_isNothingPlaying) || (playingItem != null && _isNothingPlaying)) setState(() => _isNothingPlaying = playingItem == null); }); super.initState(); } Color get backgroundColor => widget.backgroundColor ?? Theme.of(context).navigationBarTheme.backgroundColor ?? Theme.of(context).colorScheme.surface; @override void dispose() { focusNode.dispose(); mediaItemSub.cancel(); super.dispose(); } bool _gestureRegistered = false; @override Widget build(BuildContext context) { return SizedBox( height: 68.0, child: _isNothingPlaying ? null : GestureDetector( onHorizontalDragUpdate: (details) async { if (_gestureRegistered) return; final double sensitivity = 12.69; //Right swipe _gestureRegistered = true; if (details.delta.dx > sensitivity) { await audioHandler.skipToPrevious(); } //Left if (details.delta.dx < -sensitivity) { await audioHandler.skipToNext(); } _gestureRegistered = false; return; }, child: Column(mainAxisSize: MainAxisSize.min, children: [ Expanded( child: StreamBuilder( stream: audioHandler.mediaItem, initialData: audioHandler.mediaItem.valueOrNull, builder: (context, snapshot) { if (!snapshot.hasData) return const SizedBox(); final currentMediaItem = snapshot.data!; final image = CachedImage( width: 50, height: 50, url: currentMediaItem.extras!['thumb'] ?? currentMediaItem.artUri.toString(), ); final leadingWidget = widget.shouldHaveHero ? Hero(tag: currentMediaItem.id, child: image) : image; return Material( child: ListTile( // For Android TV: indicate focus by grey tileColor: focusNode.hasFocus ? Color.lerp( backgroundColor, Colors.grey, 0.26) : backgroundColor, dense: true, focusNode: focusNode, contentPadding: EdgeInsets.symmetric(horizontal: 8.0), onTap: widget.shouldHandleClicks ? _pushPlayerScreen : null, leading: AnimatedSwitcher( 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), ); }), ), ]), ), ); } void _pushPlayerScreen() { final builder = (BuildContext context) => PlayerScreen(); Navigator.of(context).pushRoute(builder: builder); } } class PrevNextButton extends StatelessWidget { final double size; final bool prev; final bool hidePrev; PrevNextButton(this.size, {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( 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( 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 Color? iconColor; /// The color of the card if [filled] is true final Color? color; const PlayPauseButton( this.size, { Key? key, this.filled = false, this.color, this.iconColor, }) : super(key: key); @override _PlayPauseButtonState createState() => _PlayPauseButtonState(); } class _PlayPauseButtonState extends State with SingleTickerProviderStateMixin { late AnimationController _controller = AnimationController(vsync: this, duration: Duration(milliseconds: 200)); late Animation _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); late StreamSubscription _subscription; late bool _canPlay = audioHandler.playbackState.value.playing || audioHandler.playbackState.value.processingState == AudioProcessingState.ready; @override void initState() { _subscription = audioHandler.playbackState.listen((playbackState) { if (playbackState.playing || playbackState.processingState == AudioProcessingState.ready) { if (playbackState.playing) _controller.forward(); else _controller.reverse(); if (!_canPlay) setState(() => _canPlay = true); return; } setState(() => _canPlay = false); }); super.initState(); } @override void dispose() { _subscription.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) return IconButton( color: widget.iconColor, icon: icon, iconSize: widget.size, onPressed: _playPause); child = InkWell( customBorder: const CircleBorder(), child: IconTheme.merge( child: Center(child: icon), data: IconThemeData( size: widget.size / 2, color: widget.iconColor)), onTap: _playPause); } else switch (audioHandler.playbackState.value.processingState) { //Stopped/Error case AudioProcessingState.error: case AudioProcessingState.idle: child = null; break; //Loading, connecting, rewinding... default: child = const Center(child: CircularProgressIndicator()); break; } if (widget.filled) return SizedBox.square( dimension: widget.size, child: Material( type: MaterialType.canvas, shape: const CircleBorder(), elevation: 2.0, child: AnimatedContainer( duration: const Duration(seconds: 1), decoration: BoxDecoration(shape: BoxShape.circle, color: widget.color), child: child), )); else return SizedBox.square(dimension: widget.size, child: child); } }