diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index a456dcd3..58073049 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'cache/metadata_prefetcher.dart'; import 'cache/prefetcher.dart'; import 'cache/sync_controller.dart'; import 'shared/routing.dart'; @@ -27,6 +28,11 @@ class _MinstrelAppState extends ConsumerState { // ignore: unawaited_futures ref.read(syncControllerProvider.notifier).sync(); ref.read(prefetcherProvider); + // Metadata prefetcher: when /api/home returns, fire background + // albumProvider/artistProvider reads for the top-N items in + // each section so subsequent taps are drift hits, not network + // round trips. + ref.read(metadataPrefetcherProvider); }); } diff --git a/flutter_client/lib/cache/metadata_prefetcher.dart b/flutter_client/lib/cache/metadata_prefetcher.dart new file mode 100644 index 00000000..d1bea408 --- /dev/null +++ b/flutter_client/lib/cache/metadata_prefetcher.dart @@ -0,0 +1,77 @@ +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +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. +/// +/// All reads are unawaited and idempotent — providers that already +/// have a cached value short-circuit. Readers/writers don't conflict +/// because each provider de-duplicates concurrent subscribers. +class MetadataPrefetcher { + MetadataPrefetcher(this._ref) { + _ref.listen>(homeProvider, (_, next) { + next.whenData(_warmHome); + }); + } + + final Ref _ref; + + /// Cap per section so a 50-album recently-added row doesn't fan + /// out 50 fetches the moment the screen appears. Top-N covers what + /// the user can see without scrolling on a typical phone. + static const _topN = 8; + + void _warmHome(HomeData h) { + final albumIds = {}; + final artistIds = {}; + + for (final a in h.recentlyAddedAlbums.take(_topN)) { + if (a.id.isNotEmpty) albumIds.add(a.id); + if (a.artistId.isNotEmpty) artistIds.add(a.artistId); + } + for (final a in h.rediscoverAlbums.take(_topN)) { + if (a.id.isNotEmpty) albumIds.add(a.id); + if (a.artistId.isNotEmpty) artistIds.add(a.artistId); + } + for (final ar in h.rediscoverArtists.take(_topN)) { + if (ar.id.isNotEmpty) artistIds.add(ar.id); + } + for (final ar in h.lastPlayedArtists.take(_topN)) { + if (ar.id.isNotEmpty) artistIds.add(ar.id); + } + for (final t in h.mostPlayedTracks.take(_topN)) { + if (t.albumId.isNotEmpty) albumIds.add(t.albumId); + if (t.artistId.isNotEmpty) artistIds.add(t.artistId); + } + + debugPrint( + 'metadataPrefetcher: warming ${albumIds.length} albums + ${artistIds.length} artists'); + + for (final id in albumIds) { + _swallow(_ref.read(albumProvider(id).future)); + } + for (final id in artistIds) { + _swallow(_ref.read(artistProvider(id).future)); + } + } + + /// Discards the return value and any error from a fire-and-forget + /// provider read. We don't care about the value here — we only want + /// the side effect of writing drift. + void _swallow(Future f) { + f.then((_) {}).onError((_, __) {}); + } +} + +/// Read once at app start to activate the prefetcher (e.g. wire it +/// from a top-level Consumer or main.dart container override). +final metadataPrefetcherProvider = Provider((ref) { + return MetadataPrefetcher(ref); +});