feat(flutter): mini ↔ full player transition + flesh out NowPlayingScreen
Mini player simplifies to track-info + prev/play/next. The shuffle/repeat/queue cluster moves to the full player, where it has the room to breathe. Full player (NowPlayingScreen) now has: - Real album art (FileImage when artUri is file://, ServerImage from /api/albums/<id>/cover otherwise — same auth-aware loader as the rest of the app, with errorBuilder so a failed fetch falls back to a slate placeholder instead of a broken-image glyph). - Title row with inline like + kebab. - Full-width seek with start/end timestamps below. - 36/72/36 prev/play/next. - Secondary row: shuffle, repeat, queue. Mini ↔ Full transition mechanics: - Tap mini → push /now-playing with custom slide-up transition (CustomTransitionPage, 280ms easeOutCubic). - Vertical drag-up flick on mini (>200 px/s) → same push. - System back / leading expand-more icon → Navigator.pop, which the CustomTransitionPage replays as a slide-down (240ms easeInCubic). - Drag-down on full player past 80px OR a >500 px/s downward flick → Navigator.pop. Buttons and the seek slider stay tappable since Flutter's gesture arena gives priority to the more-specific child recognizers. - Mini player hides while /now-playing is the active route — the full player IS the player UI in that state, no need to fight over visual space.
This commit is contained in:
@@ -67,7 +67,32 @@ GoRouter buildRouter(Ref ref) {
|
||||
path: '/albums/:id',
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
||||
GoRoute(
|
||||
path: '/now-playing',
|
||||
// Slide-up transition: full player ascends from the bottom
|
||||
// edge (matching where the mini player sits) into a full
|
||||
// screen view. Drag-down dismissal in NowPlayingScreen
|
||||
// mirrors this with a slide back down via Navigator.pop.
|
||||
pageBuilder: (_, __) => CustomTransitionPage(
|
||||
child: const NowPlayingScreen(),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||
transitionsBuilder: (_, anim, __, child) {
|
||||
final eased = CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(eased),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||
@@ -101,6 +126,11 @@ class _ShellWithPlayerBar extends ConsumerWidget {
|
||||
// When the banner is showing, strip that inset from the child so
|
||||
// its AppBar lands directly under the banner.
|
||||
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
||||
// Hide the mini player when the full player is on top — the full
|
||||
// player IS the player UI in that state, and overlapping them
|
||||
// would just fight for visual space.
|
||||
final loc = GoRouterState.of(context).matchedLocation;
|
||||
final hideMini = loc == '/now-playing';
|
||||
return Column(
|
||||
children: [
|
||||
const UpdateBanner(),
|
||||
@@ -113,7 +143,7 @@ class _ShellWithPlayerBar extends ConsumerWidget {
|
||||
)
|
||||
: child,
|
||||
),
|
||||
const PlayerBar(),
|
||||
if (!hideMini) const PlayerBar(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user