feat(flutter): infinite scroll on library Artists + Albums tabs
Both providers were FutureProvider<Paged<T>> returning a single page of 50, so once the user scrolled past 50 items the list just ended. Replace with AsyncNotifier<Paged<T>> that exposes loadMore(): fetches the next page using items.length as the offset and appends to the existing list. Idempotent guard via _loadingMore flag — concurrent scroll events near the bottom collapse to a single fetch. Both tabs now wrap the GridView in NotificationListener<ScrollNotification> that fires loadMore() when within 800px of maxScrollExtent. The lookahead is enough that the next page lands before the user hits the visible bottom on a typical phone scroll. Pull-to-refresh updated to invalidate + re-await the provider so a manual refresh always starts from the first page.
This commit is contained in:
@@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
return MeApi(await ref.watch(dioProvider.future));
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
||||||
});
|
/// pages append to the existing items rather than replacing them.
|
||||||
|
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
|
||||||
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
@override
|
||||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
Future<wire.Paged<ArtistRef>> build() async {
|
||||||
});
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||||
|
return api.listArtists(limit: _pageSize, offset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_loadingMore) return;
|
||||||
|
final cur = state.value;
|
||||||
|
if (cur == null) return;
|
||||||
|
if (cur.items.length >= cur.total) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final next = await api.listArtists(
|
||||||
|
limit: _pageSize,
|
||||||
|
offset: cur.items.length,
|
||||||
|
);
|
||||||
|
state = AsyncData(wire.Paged(
|
||||||
|
items: [...cur.items, ...next.items],
|
||||||
|
total: next.total,
|
||||||
|
limit: next.limit,
|
||||||
|
offset: 0,
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow — the next near-bottom event will retry. The current
|
||||||
|
// partial list stays visible.
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _libraryArtistsProvider = AsyncNotifierProvider<
|
||||||
|
_LibraryArtistsNotifier,
|
||||||
|
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
||||||
|
|
||||||
|
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<wire.Paged<AlbumRef>> build() async {
|
||||||
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||||
|
return api.listAlbums(limit: _pageSize, offset: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_loadingMore) return;
|
||||||
|
final cur = state.value;
|
||||||
|
if (cur == null) return;
|
||||||
|
if (cur.items.length >= cur.total) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final next = await api.listAlbums(
|
||||||
|
limit: _pageSize,
|
||||||
|
offset: cur.items.length,
|
||||||
|
);
|
||||||
|
state = AsyncData(wire.Paged(
|
||||||
|
items: [...cur.items, ...next.items],
|
||||||
|
total: next.total,
|
||||||
|
limit: next.limit,
|
||||||
|
offset: 0,
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow — next scroll event will retry.
|
||||||
|
} finally {
|
||||||
|
_loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
||||||
|
_LibraryAlbumsNotifier,
|
||||||
|
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
||||||
|
|
||||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||||
return (await ref.watch(_meApiProvider.future)).history();
|
return (await ref.watch(_meApiProvider.future)).history();
|
||||||
@@ -134,24 +210,42 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
data: (page) => page.items.isEmpty
|
data: (page) => page.items.isEmpty
|
||||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
onRefresh: () async {
|
||||||
child: GridView.builder(
|
ref.invalidate(_libraryArtistsProvider);
|
||||||
padding: const EdgeInsets.all(8),
|
await ref.read(_libraryArtistsProvider.future);
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
},
|
||||||
crossAxisCount: 3,
|
// Listen for scroll-near-bottom and ask the notifier
|
||||||
mainAxisSpacing: 8,
|
// to fetch the next page. 800px lookahead so the next
|
||||||
crossAxisSpacing: 8,
|
// page lands before the user reaches the visible end.
|
||||||
childAspectRatio: 0.78,
|
child: NotificationListener<ScrollNotification>(
|
||||||
),
|
onNotification: (n) {
|
||||||
itemCount: page.items.length,
|
if (n.metrics.pixels >=
|
||||||
itemBuilder: (ctx, i) {
|
n.metrics.maxScrollExtent - 800) {
|
||||||
final artist = page.items[i];
|
ref
|
||||||
return ArtistCard(
|
.read(_libraryArtistsProvider.notifier)
|
||||||
artist: artist,
|
.loadMore();
|
||||||
onTap: () => ctx.push('/artists/${artist.id}',
|
}
|
||||||
extra: artist),
|
return false;
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
childAspectRatio: 0.78,
|
||||||
|
),
|
||||||
|
itemCount: page.items.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final artist = page.items[i];
|
||||||
|
return ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
onTap: () => ctx.push('/artists/${artist.id}',
|
||||||
|
extra: artist),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -170,7 +264,10 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
data: (page) => page.items.isEmpty
|
data: (page) => page.items.isEmpty
|
||||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
onRefresh: () async {
|
||||||
|
ref.invalidate(_libraryAlbumsProvider);
|
||||||
|
await ref.read(_libraryAlbumsProvider.future);
|
||||||
|
},
|
||||||
// Same responsive 3-up grid as the artist detail
|
// Same responsive 3-up grid as the artist detail
|
||||||
// album list — LayoutBuilder + AlbumCard sized to the
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
// cell, mainAxisExtent matched to actual card height.
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
@@ -185,25 +282,37 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||||
// + artist line (~16) + small fudge.
|
// + artist line (~16) + small fudge.
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
||||||
return GridView.builder(
|
return NotificationListener<ScrollNotification>(
|
||||||
padding: const EdgeInsets.all(sidePad),
|
onNotification: (n) {
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
if (n.metrics.pixels >=
|
||||||
crossAxisCount: cols,
|
n.metrics.maxScrollExtent - 800) {
|
||||||
mainAxisExtent: cellH,
|
ref
|
||||||
mainAxisSpacing: gap,
|
.read(_libraryAlbumsProvider.notifier)
|
||||||
crossAxisSpacing: gap,
|
.loadMore();
|
||||||
),
|
}
|
||||||
itemCount: page.items.length,
|
return false;
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final album = page.items[i];
|
|
||||||
return AlbumCard(
|
|
||||||
album: album,
|
|
||||||
width: cellW,
|
|
||||||
titleMaxLines: 2,
|
|
||||||
onTap: () => ctx.push('/albums/${album.id}',
|
|
||||||
extra: album),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(sidePad),
|
||||||
|
gridDelegate:
|
||||||
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
mainAxisExtent: cellH,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
),
|
||||||
|
itemCount: page.items.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final album = page.items[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
onTap: () => ctx.push('/albums/${album.id}',
|
||||||
|
extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user