diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 7525c0dd..627b3e7c 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -173,6 +173,9 @@ class _NowPlayingScreenState extends ConsumerState { ParsedLyrics _lyrics = ParsedLyrics.empty; bool _loadingMeta = false; int _currentPage = 0; + bool _bottomDragForwarding = false; + double _bottomDragTotal = 0; + bool _queueSheetShowing = false; @override void initState() { @@ -389,15 +392,23 @@ class _NowPlayingScreenState extends ConsumerState { ], ), ), - _PageTabBar( - controller: _pageController, - colorScheme: colorScheme, - labels: [ - context.l10n.nowPlayingTabPlayer, - context.l10n.nowPlayingTabLyrics, - ], + _queueSwipeRegion( + colorScheme, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _PageTabBar( + controller: _pageController, + colorScheme: colorScheme, + labels: [ + context.l10n.nowPlayingTabPlayer, + context.l10n.nowPlayingTabLyrics, + ], + ), + const SizedBox(height: 8), + ], + ), ), - const SizedBox(height: 8), ], ), ), @@ -405,6 +416,44 @@ class _NowPlayingScreenState extends ConsumerState { ); } + /// Swipe up (player content or bottom tab strip) opens the queue sheet; a + /// downward drag is forwarded to the route's drag-to-dismiss instead. + Widget _queueSwipeRegion(ColorScheme colorScheme, Widget child) { + final route = ModalRoute.of(context); + final npRoute = route is NowPlayingRoute ? route : null; + final pageHeight = MediaQuery.sizeOf(context).height; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: (_) { + _bottomDragForwarding = false; + _bottomDragTotal = 0; + }, + onVerticalDragUpdate: (details) { + if (_bottomDragForwarding) { + npRoute?.updateDrag(details, pageHeight); + return; + } + _bottomDragTotal += details.primaryDelta ?? 0; + if (_bottomDragTotal > 12 && npRoute != null) { + npRoute.startDrag(); + _bottomDragForwarding = true; + } + }, + onVerticalDragEnd: (details) { + if (_bottomDragForwarding) { + npRoute?.endDrag(details, pageHeight); + } else if ((details.primaryVelocity ?? 0) < -300 || + _bottomDragTotal < -40) { + _showQueueSheet(colorScheme); + } + }, + onVerticalDragCancel: () { + if (_bottomDragForwarding) npRoute?.cancelDrag(); + }, + child: child, + ); + } + /// 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) { @@ -437,12 +486,27 @@ class _NowPlayingScreenState extends ConsumerState { child: SizedBox( width: artSize, height: artSize, - child: PlayerArtwork( - artUri: mediaItem.artUri?.toString(), - colorScheme: colorScheme, - cacheWidth: - (artSize * MediaQuery.devicePixelRatioOf(context)) - .round(), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 450), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.94, end: 1.0).animate(animation), + child: child, + ), + ), + child: PlayerArtwork( + key: ValueKey( + mediaItem.artUri?.toString() ?? mediaItem.id, + ), + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: + (artSize * MediaQuery.devicePixelRatioOf(context)) + .round(), + ), ), ), ), @@ -483,17 +547,25 @@ class _NowPlayingScreenState extends ConsumerState { } final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0); - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 16), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight - 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - artworkAt(artSize), - const SizedBox(height: 32), - ..._metadataAndControls(mediaItem, controller, colorScheme), - ], + // Not user-scrollable: a swipe up here opens the queue instead, + // and a swipe down still dismisses the player via the route. + return _queueSwipeRegion( + colorScheme, + SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 16), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + artworkAt(artSize), + const SizedBox(height: 32), + ..._metadataAndControls(mediaItem, controller, colorScheme), + ], + ), ), ), ); @@ -511,29 +583,45 @@ class _NowPlayingScreenState extends ConsumerState { return [ Padding( padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - children: [ - Text( - mediaItem.title, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(animation), + child: child, ), - const SizedBox(height: 6), - Text( - mediaItem.artist ?? '', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + child: Column( + key: ValueKey(mediaItem.id), + children: [ + Text( + mediaItem.title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 6), + Text( + mediaItem.artist ?? '', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), const SizedBox(height: 24), @@ -637,6 +725,8 @@ class _NowPlayingScreenState extends ConsumerState { } void _showQueueSheet(ColorScheme colorScheme) { + if (_queueSheetShowing) return; + _queueSheetShowing = true; showModalBottomSheet( context: context, isScrollControlled: true, @@ -819,7 +909,7 @@ class _NowPlayingScreenState extends ConsumerState { }, ); }, - ); + ).whenComplete(() => _queueSheetShowing = false); } void _showDetailsSheet(ColorScheme colorScheme) {