11c40c6aca
Three changes addressing the cold-start spinner + stale-on-revisit pain. A. SWR on remaining cacheFirst providers - artistProvider, artistAlbumsProvider, artistTracksProvider all gain alwaysRefresh: true. Cache hit still renders instantly; one background refresh per subscription keeps the row from going stale forever. - albumProvider (inline async*) and playlistDetailProvider (inline async*) now keep a `revalidated` flag and kick a one-shot background fetch on the first complete cache hit. Same effect as cacheFirst's alwaysRefresh, just inline. - Three providers gained the connectivity timeout that album/artist/playlist already had. B. Navigation hydration - AlbumDetailScreen accepts `AlbumRef? seed`; ArtistDetailScreen accepts `ArtistRef? seed`. When the live provider is still loading, the seed populates cover/title/artist immediately so the page isn't blank. - Routing wires `extra: AlbumRef|ArtistRef` from go_router into the seed parameter. - Call sites updated: home (Recently added, Rediscover albums + artists, Last played), artist detail album grid. Where a ref isn't available (track actions sheet), the screen falls back to the spinner — no regression. C. Cold-start home skeleton - Replace the full-screen CircularProgressIndicator on /home with a layout-preserving skeleton: 5 section titles + 6 grey card-shaped placeholders per row. The page feels populated immediately; sections fill in independently as data arrives via the per-section providers. - Drops the unused DelayedLoading import. Net effect: re-visits to detail screens render instantly (cache hit + silent refresh); first visit from a tile shows the seed header immediately while tracks load; cold-start home shows a layout skeleton instead of a 30s blank spinner.
159 lines
6.4 KiB
Dart
159 lines
6.4 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 '../models/album.dart';
|
|
import '../models/artist.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()),
|
|
// /now-playing lives outside the ShellRoute on purpose. The full
|
|
// player IS the player UI when active, and we don't want the mini
|
|
// bar from the shell underneath fighting for the bottom strip.
|
|
// Pushing this route unmounts the shell entirely; the slide-up
|
|
// transition still feels right because the shell stays painted
|
|
// for the duration of the animation.
|
|
GoRoute(
|
|
path: '/now-playing',
|
|
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,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
ShellRoute(
|
|
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
|
routes: [
|
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
|
GoRoute(
|
|
path: '/artists/:id',
|
|
// `extra` carries an optional ArtistRef so the detail
|
|
// header renders immediately while tracks/albums load.
|
|
builder: (_, s) => ArtistDetailScreen(
|
|
id: s.pathParameters['id']!,
|
|
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
|
),
|
|
),
|
|
GoRoute(
|
|
path: '/albums/:id',
|
|
builder: (_, s) => AlbumDetailScreen(
|
|
id: s.pathParameters['id']!,
|
|
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
|
),
|
|
),
|
|
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;
|
|
return Column(
|
|
children: [
|
|
const UpdateBanner(),
|
|
Expanded(
|
|
child: hasBanner
|
|
? MediaQuery.removePadding(
|
|
context: context,
|
|
removeTop: true,
|
|
child: child,
|
|
)
|
|
: child,
|
|
),
|
|
const PlayerBar(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|