From 8c00ee21c7e78da6eca860576de94c4981de4be4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 15:41:01 -0400 Subject: [PATCH] feat(flutter): infinite scroll on library Artists + Albums tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both providers were FutureProvider> returning a single page of 50, so once the user scrolled past 50 items the list just ended. Replace with AsyncNotifier> 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 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. --- .../lib/library/library_screen.dart | 193 ++++++++++++++---- 1 file changed, 151 insertions(+), 42 deletions(-) diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index c198fb7a..79f47e6e 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider((ref) async { return MeApi(await ref.watch(dioProvider.future)); }); -final _libraryArtistsProvider = FutureProvider>((ref) async { - return (await ref.watch(_libraryListsApiProvider.future)).listArtists(); -}); +/// Paginated artist list. AsyncNotifier so the screen can call +/// `loadMore()` when the scroll approaches the bottom; subsequent +/// pages append to the existing items rather than replacing them. +class _LibraryArtistsNotifier extends AsyncNotifier> { + static const _pageSize = 50; + bool _loadingMore = false; -final _libraryAlbumsProvider = FutureProvider>((ref) async { - return (await ref.watch(_libraryListsApiProvider.future)).listAlbums(); -}); + @override + Future> build() async { + final api = await ref.watch(_libraryListsApiProvider.future); + return api.listArtists(limit: _pageSize, offset: 0); + } + + Future 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>(_LibraryArtistsNotifier.new); + +class _LibraryAlbumsNotifier extends AsyncNotifier> { + static const _pageSize = 50; + bool _loadingMore = false; + + @override + Future> build() async { + final api = await ref.watch(_libraryListsApiProvider.future); + return api.listAlbums(limit: _pageSize, offset: 0); + } + + Future 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>(_LibraryAlbumsNotifier.new); final _historyProvider = FutureProvider((ref) async { return (await ref.watch(_meApiProvider.future)).history(); @@ -134,24 +210,42 @@ class _ArtistsTab extends ConsumerWidget { 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)) : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), - 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), - ); + onRefresh: () async { + ref.invalidate(_libraryArtistsProvider); + await ref.read(_libraryArtistsProvider.future); + }, + // Listen for scroll-near-bottom and ask the notifier + // to fetch the next page. 800px lookahead so the next + // page lands before the user reaches the visible end. + child: NotificationListener( + onNotification: (n) { + if (n.metrics.pixels >= + n.metrics.maxScrollExtent - 800) { + ref + .read(_libraryArtistsProvider.notifier) + .loadMore(); + } + 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 ? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) : 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 // album list — LayoutBuilder + AlbumCard sized to the // cell, mainAxisExtent matched to actual card height. @@ -185,25 +282,37 @@ class _AlbumsTab extends ConsumerWidget { // cover (cellW - 16) + gap (8) + 2-line title (~36) // + artist line (~16) + small fudge. final cellH = (cellW - 16) + 8 + 36 + 16 + 4; - return 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), - ); + return NotificationListener( + onNotification: (n) { + if (n.metrics.pixels >= + n.metrics.maxScrollExtent - 800) { + ref + .read(_libraryAlbumsProvider.notifier) + .loadMore(); + } + return false; }, + 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), + ); + }, + ), ); }), ),