feat(flutter): playlist detail nav hydration — header renders before fetch
Same pattern as album + artist detail. PlaylistDetailScreen accepts optional Playlist seed via go_router extra. While playlistDetailProvider is still resolving, render the header (description if any + track count) plus a "loading tracks…" inline spinner instead of an opaque full-screen CircularProgressIndicator. The body fills in when tracks arrive via drift watch re-emit. Routing reads s.extra as Playlist. PlaylistCard + PlaylistsListScreen pass the playlist via extra. Surfaces with no seed available (deep links etc.) fall back to the original full- screen spinner. Track row rendering already uses ListView.builder so visible row count was never the bottleneck — the wait was for the detail fetch itself. Header-on-tap is the win that makes the screen feel snappy.
This commit is contained in:
@@ -12,13 +12,26 @@ import '../theme/theme_extension.dart';
|
|||||||
import 'playlists_provider.dart';
|
import 'playlists_provider.dart';
|
||||||
|
|
||||||
class PlaylistDetailScreen extends ConsumerWidget {
|
class PlaylistDetailScreen extends ConsumerWidget {
|
||||||
const PlaylistDetailScreen({required this.id, super.key});
|
const PlaylistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional Playlist passed via go_router extra so the header
|
||||||
|
/// (title, cover, track count, play/download CTAs) can render
|
||||||
|
/// before the full detail fetch resolves. Same pattern as album
|
||||||
|
/// + artist nav hydration.
|
||||||
|
final Playlist? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final detail = ref.watch(playlistDetailProvider(id));
|
final detail = ref.watch(playlistDetailProvider(id));
|
||||||
|
|
||||||
|
// Resolve the best playlist info available for the AppBar title.
|
||||||
|
final livePlaylist = detail.value?.playlist;
|
||||||
|
final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty)
|
||||||
|
? livePlaylist.name
|
||||||
|
: (seed?.name ?? '');
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -28,17 +41,21 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
|||||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: detail.maybeWhen(
|
title: headerName.isEmpty
|
||||||
data: (d) => Text(
|
? const SizedBox.shrink()
|
||||||
d.playlist.name,
|
: Text(
|
||||||
style: TextStyle(color: fs.parchment),
|
headerName,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(color: fs.parchment),
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
orElse: () => const SizedBox.shrink(),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: detail.when(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
// Loading from a seed: render the header immediately + a
|
||||||
|
// small inline "loading tracks" hint, instead of an opaque
|
||||||
|
// full-screen spinner. The body fills in when tracks arrive.
|
||||||
|
loading: () => seed == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _SeedBody(playlist: seed!),
|
||||||
error: (e, _) =>
|
error: (e, _) =>
|
||||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (d) => _Body(detail: d),
|
data: (d) => _Body(detail: d),
|
||||||
@@ -47,6 +64,43 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loading-state body: renders just the header from the seed
|
||||||
|
/// playlist + a "loading tracks…" placeholder. CTA buttons are
|
||||||
|
/// hidden until the real track list arrives (they need playable
|
||||||
|
/// refs to do anything useful).
|
||||||
|
class _SeedBody extends StatelessWidget {
|
||||||
|
const _SeedBody({required this.playlist});
|
||||||
|
final Playlist playlist;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ListView(children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
if (playlist.description.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
playlist.description,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _Body extends ConsumerWidget {
|
class _Body extends ConsumerWidget {
|
||||||
const _Body({required this.detail});
|
const _Body({required this.detail});
|
||||||
final PlaylistDetail detail;
|
final PlaylistDetail detail;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class PlaylistsListScreen extends ConsumerWidget {
|
|||||||
final p = items[i];
|
final p = items[i];
|
||||||
return _PlaylistTile(
|
return _PlaylistTile(
|
||||||
playlist: p,
|
playlist: p,
|
||||||
onTap: () => ctx.push('/playlists/${p.id}'),
|
onTap: () => ctx.push('/playlists/${p.id}', extra: p),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class PlaylistCard extends StatelessWidget {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.push('/playlists/${playlist.id}'),
|
onTap: () =>
|
||||||
|
context.push('/playlists/${playlist.id}', extra: playlist),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../library/album_detail_screen.dart';
|
|||||||
import '../library/artist_detail_screen.dart';
|
import '../library/artist_detail_screen.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
import '../discover/discover_screen.dart';
|
import '../discover/discover_screen.dart';
|
||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../library/library_screen.dart';
|
import '../library/library_screen.dart';
|
||||||
@@ -120,7 +121,10 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/playlists/:id',
|
path: '/playlists/:id',
|
||||||
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
builder: (_, s) => PlaylistDetailScreen(
|
||||||
|
id: s.pathParameters['id']!,
|
||||||
|
seed: s.extra is Playlist ? s.extra as Playlist : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
||||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||||
|
|||||||
Reference in New Issue
Block a user