From f95e2d6d017149a0994ac483e06f39a551161ddc Mon Sep 17 00:00:00 2001 From: Phan An Date: Sun, 22 Mar 2026 23:33:47 +0100 Subject: [PATCH] feat: add Hero animation and swipe-to-dismiss for Now Playing screen Replace showModalBottomSheet with a custom PageRoute that supports Hero animations on the album thumbnail between MiniPlayer and NowPlayingScreen. Add swipe-down-to-dismiss gesture, drag handle, and platform-aware rounded corners (iOS only, matching device radius). Also improve MiniPlayer progress bar precision (milliseconds instead of seconds) and clamp to prevent overflow. --- lib/router.dart | 14 ++---- lib/ui/screens/now_playing.dart | 51 ++++++++++++++++++- lib/ui/widgets/mini_player.dart | 9 ++-- lib/ui/widgets/now_playing_page_route.dart | 57 ++++++++++++++++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 lib/ui/widgets/now_playing_page_route.dart diff --git a/lib/router.dart b/lib/router.dart index 724b838f..b151d0ae 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,5 +1,6 @@ import 'package:app/models/models.dart'; import 'package:app/ui/screens/screens.dart'; +import 'package:app/ui/widgets/now_playing_page_route.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -60,17 +61,8 @@ class AppRouter { } Future openNowPlayingScreen(BuildContext context) async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (BuildContext context) { - return Container( - height: MediaQuery.of(context).size.height, - child: const NowPlayingScreen(), - ); - }, + await Navigator.of(context, rootNavigator: true).push( + NowPlayingPageRoute(builder: (_) => const NowPlayingScreen()), ); } diff --git a/lib/ui/screens/now_playing.dart b/lib/ui/screens/now_playing.dart index ce09e1f9..51b93d97 100644 --- a/lib/ui/screens/now_playing.dart +++ b/lib/ui/screens/now_playing.dart @@ -29,6 +29,7 @@ class _NowPlayingScreenState extends State PlaybackState? _state; Playable? _playable; late PlayableProvider _playableProvider; + var _dragOffset = 0.0; @override void initState() { @@ -52,6 +53,24 @@ class _NowPlayingScreenState extends State super.dispose(); } + void _onVerticalDragUpdate(DragUpdateDetails details) { + if (details.delta.dy < 0 && _dragOffset <= 0) return; + setState(() { + _dragOffset = (_dragOffset + details.delta.dy).clamp(0.0, double.infinity); + }); + } + + void _onVerticalDragEnd(DragEndDetails details) { + final screenHeight = MediaQuery.of(context).size.height; + final velocity = details.primaryVelocity ?? 0; + + if (_dragOffset > screenHeight * 0.2 || velocity > 800) { + Navigator.of(context).pop(); + } else { + setState(() => _dragOffset = 0); + } + } + @override Widget build(BuildContext context) { final playable = _playable; @@ -110,7 +129,22 @@ class _NowPlayingScreenState extends State ], ); - return Stack( + return GestureDetector( + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + child: AnimatedContainer( + duration: _dragOffset == 0 + ? const Duration(milliseconds: 200) + : Duration.zero, + curve: Curves.easeOut, + transform: Matrix4.translationValues(0, _dragOffset, 0), + child: ClipRRect( + borderRadius: Theme.of(context).platform == TargetPlatform.iOS + ? const BorderRadius.vertical(top: Radius.circular(38.5)) + : BorderRadius.zero, + child: Material( + type: MaterialType.transparency, + child: Stack( children: [ const GradientDecoratedContainer(), frostBackground, @@ -125,6 +159,17 @@ class _NowPlayingScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + // Drag handle + Center( + child: Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: Colors.white30, + borderRadius: BorderRadius.circular(2.5), + ), + ), + ), thumbnail, infoPane, const AudioControls(), @@ -166,6 +211,10 @@ class _NowPlayingScreenState extends State ), ), ], + ), + ), + ), + ), ); } } diff --git a/lib/ui/widgets/mini_player.dart b/lib/ui/widgets/mini_player.dart index 009deea2..80784f20 100644 --- a/lib/ui/widgets/mini_player.dart +++ b/lib/ui/widgets/mini_player.dart @@ -226,7 +226,7 @@ class _MiniPlayerProgressBarState extends State subscribe(audioHandler.mediaItem.listen((mediaItem) { if (mediaItem != null && mediaItem.duration != null) { - setState(() => _duration = mediaItem.duration ?? Duration.zero); + setState(() => _duration = mediaItem.duration!); } })); } @@ -239,7 +239,10 @@ class _MiniPlayerProgressBarState extends State @override Widget build(BuildContext context) { - if (_duration == Duration.zero) return SizedBox.shrink(); + if (_duration.inMilliseconds == 0) return SizedBox.shrink(); + + final progress = (_position.inMilliseconds / _duration.inMilliseconds) + .clamp(0.0, 1.0); return Container( width: double.infinity, @@ -247,7 +250,7 @@ class _MiniPlayerProgressBarState extends State height: 1.0, color: Colors.white12, child: FractionallySizedBox( - widthFactor: _position.inSeconds / _duration.inSeconds, + widthFactor: progress, child: Container(color: Colors.white), ), ); diff --git a/lib/ui/widgets/now_playing_page_route.dart b/lib/ui/widgets/now_playing_page_route.dart new file mode 100644 index 00000000..bd6d2246 --- /dev/null +++ b/lib/ui/widgets/now_playing_page_route.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class NowPlayingPageRoute extends PageRoute { + final WidgetBuilder builder; + + NowPlayingPageRoute({required this.builder}) + : super(fullscreenDialog: true); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + bool get opaque => false; + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => const Duration(milliseconds: 400); + + @override + Duration get reverseTransitionDuration => const Duration(milliseconds: 350); + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return builder(context); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final offsetAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + } +}