From 702fc9825eb22bfbda8d2e2d1422230fb26d256d Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 20:33:34 +0700 Subject: [PATCH] feat(player): expand transition from mini player with drag-to-dismiss Slide-up route with M3 emphasized easing, live finger-tracking dismissal on the app bar and artwork, and a shared-element Hero on the artwork so the mini player cover grows into the full player. --- lib/screens/now_playing_screen.dart | 154 ++++++++++++++++++++++++++-- lib/widgets/mini_player.dart | 30 +++--- 2 files changed, 157 insertions(+), 27 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 12b3a042..67f4cca3 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -17,6 +17,116 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; final _log = AppLogger('NowPlaying'); +/// Hero tag shared by the mini player artwork and the full player artwork so +/// the cover visually expands when the player opens. +const kNowPlayingArtworkHeroTag = 'now-playing-artwork'; + +/// Slide-up route for the full player. Supports live drag-to-dismiss: the +/// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and +/// settles open or pops based on release position and velocity. +class NowPlayingRoute extends PageRoute { + NowPlayingRoute() : super(fullscreenDialog: true); + + bool _dragging = false; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => const Duration(milliseconds: 400); + + @override + Duration get reverseTransitionDuration => const Duration(milliseconds: 300); + + void startDrag() { + _dragging = true; + controller?.stop(); + changedInternalState(); + } + + void updateDrag(DragUpdateDetails details, double pageHeight) { + controller?.value -= (details.primaryDelta ?? 0) / pageHeight; + } + + void endDrag(DragEndDetails details, double pageHeight) { + _dragging = false; + changedInternalState(); + final velocity = (details.primaryVelocity ?? 0) / pageHeight; + final value = controller?.value ?? 1.0; + if (velocity > 1.0 || (velocity >= 0 && value < 0.7)) { + navigator?.pop(); + } else { + controller?.fling(); + } + } + + void cancelDrag() { + _dragging = false; + changedInternalState(); + controller?.fling(); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return _RouteDragRegion(route: this, child: const NowPlayingScreen()); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + // Linear while dragging so the page tracks the finger 1:1. + final position = _dragging + ? animation + : CurvedAnimation( + parent: animation, + curve: Easing.emphasizedDecelerate, + reverseCurve: Easing.emphasizedAccelerate, + ); + return SlideTransition( + position: position.drive( + Tween(begin: const Offset(0, 1), end: Offset.zero), + ), + child: child, + ); + } +} + +/// Routes vertical drags to [NowPlayingRoute]. Scrollables inside [child] +/// still win the gesture arena, so this only fires on non-scrolling regions +/// (app bar, artwork, tab bar). +class _RouteDragRegion extends StatelessWidget { + final NowPlayingRoute route; + final Widget child; + + const _RouteDragRegion({required this.route, required this.child}); + + @override + Widget build(BuildContext context) { + final pageHeight = MediaQuery.sizeOf(context).height; + return GestureDetector( + onVerticalDragStart: (_) => route.startDrag(), + onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight), + onVerticalDragEnd: (details) => route.endDrag(details, pageHeight), + onVerticalDragCancel: route.cancelDrag, + child: child, + ); + } +} + class NowPlayingScreen extends ConsumerStatefulWidget { const NowPlayingScreen({super.key}); @@ -225,6 +335,21 @@ class _NowPlayingScreenState extends ConsumerState { ); } + /// The artwork sits inside a scroll view, which wins vertical drags from + /// the route-level drag region — so the artwork hooks the route directly. + Widget _artworkDragRegion(BuildContext context, Widget child) { + final route = ModalRoute.of(context); + if (route is! NowPlayingRoute) return child; + final pageHeight = MediaQuery.sizeOf(context).height; + return GestureDetector( + onVerticalDragStart: (_) => route.startDrag(), + onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight), + onVerticalDragEnd: (details) => route.endDrag(details, pageHeight), + onVerticalDragCancel: route.cancelDrag, + child: child, + ); + } + Widget _playerPage( MediaItem mediaItem, MusicPlayerController controller, @@ -252,17 +377,24 @@ class _NowPlayingScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: SizedBox( - width: artSize, - height: artSize, - child: PlayerArtwork( - artUri: mediaItem.artUri?.toString(), - colorScheme: colorScheme, - cacheWidth: - (artSize * MediaQuery.devicePixelRatioOf(context)) - .round(), + child: _artworkDragRegion( + context, + Hero( + tag: kNowPlayingArtworkHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: SizedBox( + width: artSize, + height: artSize, + child: PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: + (artSize * + MediaQuery.devicePixelRatioOf(context)) + .round(), + ), + ), ), ), ), diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 44c3888c..7498fb66 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -35,12 +35,7 @@ class MiniPlayer extends ConsumerWidget { color: settingsGroupColor(context).withValues(alpha: 0.72), child: InkWell( onTap: () { - Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => const NowPlayingScreen(), - fullscreenDialog: true, - ), - ); + Navigator.of(context, rootNavigator: true).push(NowPlayingRoute()); }, child: Column( mainAxisSize: MainAxisSize.min, @@ -54,16 +49,19 @@ class MiniPlayer extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 44, - height: 44, - child: PlayerArtwork( - artUri: mediaItem.artUri?.toString(), - colorScheme: colorScheme, - cacheWidth: 132, - iconSize: 22, + Hero( + tag: kNowPlayingArtworkHeroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 44, + height: 44, + child: PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: 132, + iconSize: 22, + ), ), ), ),