From 4bd069430b0ce5a11bda8e264d2d1574fe8bc61a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 18:52:37 -0400 Subject: [PATCH] feat(flutter): extend metadata prefetch to library tabs + artist detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetadataPrefetcher gains warmAlbums(ids) / warmArtists(ids) public methods so callers can fan out drift-cache warm-ups for whatever collection just landed. Wired into: - Library Artists tab — warms first 8 artists from the page on every data emit (initial + paginate + refresh). - Library Albums tab — same for first 8 albums. - Artist detail album grid — warms first 8 albums from the artist's album list as soon as it loads, so tapping into any of them is a drift hit. Hard-cap of 8 per call (same as the home prefetch). Set spans across calls aren't deduped at this layer because providers themselves short-circuit on cached values. Also instrument the artist-detail play button: try/catch around the artistTracksProvider read + playTracks call, snackbar on empty-tracks or thrown-error so silent failures stop being silent. The current behavior was an early return on tracks.isEmpty with no visible feedback. --- .../lib/cache/metadata_prefetcher.dart | 31 +++++++++++--- .../lib/library/artist_detail_screen.dart | 42 ++++++++++++++++--- .../lib/library/library_screen.dart | 20 +++++++-- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/flutter_client/lib/cache/metadata_prefetcher.dart b/flutter_client/lib/cache/metadata_prefetcher.dart index d1bea408..b60cc08e 100644 --- a/flutter_client/lib/cache/metadata_prefetcher.dart +++ b/flutter_client/lib/cache/metadata_prefetcher.dart @@ -5,11 +5,9 @@ import '../library/library_providers.dart'; import '../models/home_data.dart'; /// Pre-warms the drift cache for entities the user is most likely to -/// tap. When /api/home returns its sections, fire one albumProvider / -/// artistProvider read per top-of-section item in the background. The -/// reads trigger the existing cold-cache path, which writes drift. -/// By the time the user actually taps a tile, it's a drift hit and -/// the detail screen renders instantly. +/// tap. The home prefetch fires when /api/home returns. Library +/// tabs and artist detail expose `warmAlbums` / `warmArtists` so they +/// can fan out reads from their own AsyncNotifier callbacks. /// /// All reads are unawaited and idempotent — providers that already /// have a cached value short-circuit. Readers/writers don't conflict @@ -28,6 +26,29 @@ class MetadataPrefetcher { /// the user can see without scrolling on a typical phone. static const _topN = 8; + /// Pre-warm drift for each album id (no-op past the first dedup). + /// Called from the library Albums tab + artist detail album grid. + void warmAlbums(Iterable ids) { + var n = 0; + for (final id in ids) { + if (id.isEmpty) continue; + if (n++ >= _topN) break; + _swallow(_ref.read(albumProvider(id).future)); + } + if (n > 0) debugPrint('metadataPrefetcher: warming $n albums'); + } + + /// Pre-warm drift for each artist id. + void warmArtists(Iterable ids) { + var n = 0; + for (final id in ids) { + if (id.isEmpty) continue; + if (n++ >= _topN) break; + _swallow(_ref.read(artistProvider(id).future)); + } + if (n > 0) debugPrint('metadataPrefetcher: warming $n artists'); + } + void _warmHome(HomeData h) { final albumIds = {}; final artistIds = {}; diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart index e13d276d..2430bf78 100644 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ b/flutter_client/lib/library/artist_detail_screen.dart @@ -1,8 +1,10 @@ +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/endpoints/likes.dart'; +import '../cache/metadata_prefetcher.dart'; import '../likes/like_button.dart'; import '../models/album.dart'; import '../models/artist.dart'; @@ -92,10 +94,33 @@ class ArtistDetailScreen extends ConsumerWidget { child: IconButton( icon: Icon(Icons.play_arrow, color: fs.parchment), onPressed: () async { - final tracks = await ref.read(artistTracksProvider(id).future); - if (tracks.isEmpty) return; - final shuffled = [...tracks]..shuffle(); - ref.read(playerActionsProvider).playTracks(shuffled); + try { + final tracks = + await ref.read(artistTracksProvider(id).future); + debugPrint( + 'artist_detail: play tapped — ${tracks.length} tracks for $id'); + if (tracks.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'No tracks found for this artist yet.')), + ); + } + return; + } + final shuffled = [...tracks]..shuffle(); + await ref + .read(playerActionsProvider) + .playTracks(shuffled); + } catch (e) { + debugPrint('artist_detail: play failed: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Couldn't start playback: $e")), + ); + } + } }, ), ), @@ -109,7 +134,11 @@ class ArtistDetailScreen extends ConsumerWidget { albums.when( error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())), - data: (list) => LayoutBuilder(builder: (ctx, constraints) { + data: (list) { + ref + .read(metadataPrefetcherProvider) + .warmAlbums(list.map((a) => a.id)); + return LayoutBuilder(builder: (ctx, constraints) { const cols = 3; const sidePad = 8.0; const gap = 8.0; @@ -144,7 +173,8 @@ class ArtistDetailScreen extends ConsumerWidget { ); }, ); - }), + }); + }, ), ]); } diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 79f47e6e..145e7cbf 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../api/endpoints/library_lists.dart'; import '../api/endpoints/likes.dart'; import '../api/endpoints/me.dart'; +import '../cache/metadata_prefetcher.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/album.dart'; import '../models/artist.dart'; @@ -207,7 +208,12 @@ class _ArtistsTab extends ConsumerWidget { return ref.watch(_libraryArtistsProvider).when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (page) => page.items.isEmpty + data: (page) { + // Warm details for the first screenful so taps are instant. + ref + .read(metadataPrefetcherProvider) + .warmArtists(page.items.map((a) => a.id)); + return 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 { @@ -247,7 +253,8 @@ class _ArtistsTab extends ConsumerWidget { }, ), ), - ), + ); + }, ); } } @@ -261,7 +268,11 @@ class _AlbumsTab extends ConsumerWidget { return ref.watch(_libraryAlbumsProvider).when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (page) => page.items.isEmpty + data: (page) { + ref + .read(metadataPrefetcherProvider) + .warmAlbums(page.items.map((a) => a.id)); + return 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 { @@ -315,7 +326,8 @@ class _AlbumsTab extends ConsumerWidget { ), ); }), - ), + ); + }, ); } }