Files
minstrel/flutter_client/lib/shared/routing.dart
T
bvandeusen 163c1174db 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.
2026-05-11 08:06:38 -04:00

152 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../auth/auth_provider.dart';
import '../auth/login_screen.dart';
import '../auth/server_url_screen.dart';
import '../library/album_detail_screen.dart';
import '../library/artist_detail_screen.dart';
import '../discover/discover_screen.dart';
import '../library/home_screen.dart';
import '../library/library_screen.dart';
import '../player/now_playing_screen.dart';
import '../player/player_bar.dart';
import '../player/queue_screen.dart';
import '../playlists/playlist_detail_screen.dart';
import '../playlists/playlists_list_screen.dart';
import '../requests/requests_screen.dart';
import '../search/search_screen.dart';
import '../settings/settings_screen.dart';
import '../update/client_update_provider.dart';
import '../update/update_banner.dart';
import '../admin/admin_landing_screen.dart';
import '../admin/admin_requests_screen.dart';
import '../admin/admin_quarantine_screen.dart';
import '../admin/admin_users_screen.dart';
import 'widgets/version_gate.dart';
/// Exposed as a Provider so its single argument is a real `Ref` (the
/// constructor's redirect closures use `ref.read`). Widgets consume the
/// router via `ref.watch(routerProvider)` instead of constructing it
/// themselves with a `WidgetRef`, which can't satisfy the `Ref` type.
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
GoRouter buildRouter(Ref ref) {
return GoRouter(
initialLocation: '/',
redirect: (ctx, state) async {
final url = await ref.read(serverUrlProvider.future);
if (url == null) return '/server-url';
final user = await ref.read(authControllerProvider.future);
final loc = state.matchedLocation;
if (user == null && loc != '/login' && loc != '/server-url') {
return '/login';
}
if (user != null && (loc == '/login' || loc == '/server-url')) {
return '/home';
}
if (loc.startsWith('/admin') && !user!.isAdmin) {
return '/home';
}
return null;
},
routes: [
GoRoute(path: '/', redirect: (_, __) => '/home'),
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
ShellRoute(
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/artists/:id',
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(
path: '/albums/:id',
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
),
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()),
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
GoRoute(
path: '/playlists/:id',
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()),
],
),
],
);
}
class _ShellWithPlayerBar extends ConsumerWidget {
const _ShellWithPlayerBar({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
// The banner sits above the routed child and uses SafeArea to clear
// the system status bar. MediaQuery.padding is screen-relative
// though, so the child's Scaffold/AppBar would still apply its own
// top status-bar inset on top of the banner — doubling the gap.
// 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(),
Expanded(
child: hasBanner
? MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
)
: child,
),
if (!hideMini) const PlayerBar(),
],
);
}
}