From 8a332c566aa9cccfa3f5b39f6abe15982f8c8aa1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 20:53:16 +0700 Subject: [PATCH] fix(player): fade Now Playing content with the expand transition The app bar and body slid up as a block while the Hero artwork flew its own path, so the title/controls looked detached from the cover. Content now fades in over the last 60% of the transition (and fades out first on pop/drag), leaving only the sheet surface and the flying cover in motion. --- lib/screens/now_playing_screen.dart | 204 +++++++++++++++++----------- 1 file changed, 121 insertions(+), 83 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 67f4cca3..a5a2a023 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -127,6 +127,22 @@ class _RouteDragRegion extends StatelessWidget { } } +/// AppBar wrapper that fades with the route transition (see contentOpacity +/// in [_NowPlayingScreenState.build]). +class _FadingAppBar extends StatelessWidget implements PreferredSizeWidget { + final Animation opacity; + final AppBar child; + + const _FadingAppBar({required this.opacity, required this.child}); + + @override + Size get preferredSize => child.preferredSize; + + @override + Widget build(BuildContext context) => + FadeTransition(opacity: opacity, child: child); +} + class NowPlayingScreen extends ConsumerStatefulWidget { const NowPlayingScreen({super.key}); @@ -257,81 +273,100 @@ class _NowPlayingScreenState extends ConsumerState { final source = mediaItem.extras?['source']?.toString() ?? ''; + // Fade the page content in during the last 60% of the slide-up so only + // the sheet surface and the flying Hero artwork move together; without + // this the title/controls slide separately and look detached from the + // cover. On pop/drag the content fades out first, then the cover flies. + final routeAnimation = + ModalRoute.of(context)?.animation ?? kAlwaysCompleteAnimation; + final contentOpacity = CurvedAnimation( + parent: routeAnimation, + curve: const Interval(0.4, 1.0, curve: Curves.easeOut), + ); + return Scaffold( backgroundColor: colorScheme.surface, - appBar: AppBar( - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: Text(context.l10n.nowPlayingTitle), - centerTitle: true, - leading: IconButton( - tooltip: context.l10n.nowPlayingMinimize, - icon: const Icon(Icons.keyboard_arrow_down), - onPressed: () => Navigator.of(context).maybePop(), - ), - actions: [ - IconButton( - tooltip: context.l10n.nowPlayingUpNext, - icon: const Icon(Icons.queue_music), - onPressed: () => _showQueueSheet(colorScheme), + appBar: _FadingAppBar( + opacity: contentOpacity, + child: AppBar( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + title: Text(context.l10n.nowPlayingTitle), + centerTitle: true, + leading: IconButton( + tooltip: context.l10n.nowPlayingMinimize, + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: () => Navigator.of(context).maybePop(), ), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - switch (value) { - case 'details': - _showDetailsSheet(colorScheme); - break; - case 'external': - _openExternally(source); - break; - } - }, - itemBuilder: (menuContext) => [ - PopupMenuItem( - value: 'details', - child: ListTile( - leading: const Icon(Icons.info_outline), - title: Text(menuContext.l10n.nowPlayingDetails), - contentPadding: EdgeInsets.zero, - ), - ), - PopupMenuItem( - value: 'external', - child: ListTile( - leading: const Icon(Icons.open_in_new), - title: Text(menuContext.l10n.nowPlayingOpenInExternalPlayer), - contentPadding: EdgeInsets.zero, - ), - ), - ], - ), - ], - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: PageView( - controller: _pageController, - children: [ - _playerPage(mediaItem, controller, colorScheme), - _lyricsSection(colorScheme), - ], - ), + actions: [ + IconButton( + tooltip: context.l10n.nowPlayingUpNext, + icon: const Icon(Icons.queue_music), + onPressed: () => _showQueueSheet(colorScheme), ), - _PageTabBar( - controller: _pageController, - colorScheme: colorScheme, - labels: [ - context.l10n.nowPlayingTabPlayer, - context.l10n.nowPlayingTabLyrics, + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + switch (value) { + case 'details': + _showDetailsSheet(colorScheme); + break; + case 'external': + _openExternally(source); + break; + } + }, + itemBuilder: (menuContext) => [ + PopupMenuItem( + value: 'details', + child: ListTile( + leading: const Icon(Icons.info_outline), + title: Text(menuContext.l10n.nowPlayingDetails), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'external', + child: ListTile( + leading: const Icon(Icons.open_in_new), + title: Text( + menuContext.l10n.nowPlayingOpenInExternalPlayer, + ), + contentPadding: EdgeInsets.zero, + ), + ), ], ), - const SizedBox(height: 8), ], ), ), + body: FadeTransition( + opacity: contentOpacity, + child: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView( + controller: _pageController, + children: [ + _playerPage(mediaItem, controller, colorScheme), + _lyricsSection(colorScheme), + ], + ), + ), + _PageTabBar( + controller: _pageController, + colorScheme: colorScheme, + labels: [ + context.l10n.nowPlayingTabPlayer, + context.l10n.nowPlayingTabLyrics, + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ), ); } @@ -575,9 +610,11 @@ class _NowPlayingScreenState extends ConsumerState { await openFile(source); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), + ); } } @@ -591,9 +628,9 @@ class _NowPlayingScreenState extends ConsumerState { .toList(); if (media.isEmpty) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)), + ); return; } media.shuffle(); @@ -602,10 +639,12 @@ class _NowPlayingScreenState extends ConsumerState { if (mounted) Navigator.of(context).maybePop(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar(content: Text(context.l10n.nowPlayingShuffleLibraryFailed(e.toString()))), + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.nowPlayingShuffleLibraryFailed(e.toString()), + ), + ), ); } } @@ -657,8 +696,7 @@ class _NowPlayingScreenState extends ConsumerState { isSelected: shuffleOn, icon: const Icon(Icons.shuffle), color: shuffleOn ? colorScheme.primary : null, - onPressed: () => - controller.setShuffle(!shuffleOn), + onPressed: () => controller.setShuffle(!shuffleOn), ), ], ), @@ -707,12 +745,12 @@ class _NowPlayingScreenState extends ConsumerState { final isCurrent = current?.id == item.id; return ListTile( key: ValueKey('${item.id}_$i'), - contentPadding: - const EdgeInsets.only(left: 16, right: 4), + contentPadding: const EdgeInsets.only( + left: 16, + right: 4, + ), leading: Icon( - isCurrent - ? Icons.equalizer - : Icons.music_note, + isCurrent ? Icons.equalizer : Icons.music_note, color: isCurrent ? colorScheme.primary : colorScheme.onSurfaceVariant,