From 22152b1ba33f0a67c942afe600637c2750e962c5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 18:44:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20metadata=20prefetcher=20?= =?UTF-8?q?=E2=80=94=20pre-warm=20drift=20for=20likely=20tap=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Across-the-board sluggishness was every cold-cache tap doing one network round trip while the user waits. SWR helps on re-visits but the first time you tap a tile from home you eat the latency. MetadataPrefetcher listens to homeProvider. When /api/home returns, it fires fire-and-forget reads on albumProvider + artistProvider for the top-N items in each home section (recently added, rediscover, most played, last played). Each provider read triggers the existing cold-cache path, which writes to drift. By the time the user actually taps a tile, it's already a drift hit and the detail screen renders instantly. Cap N=8 per section (covers what's visible on a typical phone without scrolling). Set spans dedupe across sections so popular artists don't get fetched five times. Errors are swallowed — a failed prefetch is silent and the tile falls back to its on-tap fetch behavior. Wired alongside the existing audio prefetcher in app.dart's postFrameCallback. --- flutter_client/lib/app.dart | 6 ++ .../lib/cache/metadata_prefetcher.dart | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 flutter_client/lib/cache/metadata_prefetcher.dart 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); +});