From 32c8d4f28f8b72692f8215cb2b23b2a2c2ac25f2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 18:06:53 -0400 Subject: [PATCH] feat(flutter): pre-warm covers during library sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of the cover-caching pass. SyncController now downloads cover bytes for newly-upserted albums + playlists into the shared flutter_cache_manager disk cache after each sync transaction commits. A cold-start scroll through the home grid paints from disk on the very first frame instead of firing one HTTP per visible tile. Best-effort: fire-and-forget after commit, concurrency 3, per-URL failures swallowed (404 for collages that haven't built yet, 401 during token-refresh races). Artist covers skipped — ArtistRef.coverUrl is server-derived from "most-recent album" and not reconstructible client-side; album pre-warm already covers the artist's primary visual. Auth header reuses sessionTokenProvider for parity with ServerImage. --- flutter_client/lib/cache/sync_controller.dart | 74 ++++++++++++++++++- flutter_client/pubspec.lock | 2 +- flutter_client/pubspec.yaml | 6 ++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/flutter_client/lib/cache/sync_controller.dart b/flutter_client/lib/cache/sync_controller.dart index 1cc3cb2f..b4f1f91a 100644 --- a/flutter_client/lib/cache/sync_controller.dart +++ b/flutter_client/lib/cache/sync_controller.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:drift/drift.dart' as drift; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider; import '../library/library_providers.dart' show dioProvider; import 'audio_cache_manager.dart' show appDbProvider; import 'db.dart'; @@ -75,6 +79,14 @@ class SyncController extends AsyncNotifier { var upsertCount = 0; var deleteCount = 0; + // IDs of upserted entities whose covers we'll pre-warm after the + // transaction commits. Filled inside the loops; consumed by + // _prewarmCovers fire-and-forget below. Artist covers are + // server-derived from "most-recent album" and aren't + // reconstructible client-side — album pre-warm already covers an + // artist's primary visual via its detail-page header. + final albumCoverIds = []; + final playlistCoverIds = []; await db.transaction(() async { // ---- Upserts ---- @@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier { upsertCount++; } for (final a in (upserts['album'] as List? ?? const [])) { + final m = a as Map; await db.into(db.cachedAlbums).insertOnConflictUpdate( - _albumFromJson(a as Map), + _albumFromJson(m), ); + final id = m['id']; + if (id is String && id.isNotEmpty) albumCoverIds.add(id); upsertCount++; } for (final t in (upserts['track'] as List? ?? const [])) { @@ -130,9 +145,12 @@ class SyncController extends AsyncNotifier { upsertCount++; } for (final p in (upserts['playlist'] as List? ?? const [])) { + final m = p as Map; await db.into(db.cachedPlaylists).insertOnConflictUpdate( - _playlistFromJson(p as Map), + _playlistFromJson(m), ); + final id = m['id']; + if (id is String && id.isNotEmpty) playlistCoverIds.add(id); upsertCount++; } for (final pt in (upserts['playlist_track'] as List? ?? const [])) { @@ -224,6 +242,15 @@ class SyncController extends AsyncNotifier { ); }); + // Fire-and-forget cover pre-warm so a cold-start scroll through + // the home grid paints from disk on the very first frame instead + // of firing one HTTP request per visible tile. Unawaited because + // the sync's "done" signal should fire as soon as the metadata + // delta is durable; cover downloads can finish in the background. + if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) { + unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds)); + } + final result = SyncResult( upserts: upsertCount, deletes: deleteCount, @@ -237,6 +264,49 @@ class SyncController extends AsyncNotifier { } } + /// Downloads cover bytes for each id into the shared flutter_cache_ + /// manager disk cache that cached_network_image reads from. + /// Best-effort: per-URL failures (404 collage-not-built-yet, 401 + /// during a token refresh race, network blip) are swallowed so one + /// missing cover doesn't abort the rest. + /// + /// Concurrency 3 balances disk-warming throughput against starving + /// foreground UI work — covers are ~30-200KB each, so three in + /// flight saturates most LAN connections without pinning the radio. + Future _prewarmCovers( + List albumIds, + List playlistIds, + ) async { + final baseUrl = await ref.read(serverUrlProvider.future); + if (baseUrl == null || baseUrl.isEmpty) return; + final token = await ref.read(sessionTokenProvider.future); + final headers = (token != null && token.isNotEmpty) + ? {'Authorization': 'Bearer $token'} + : {}; + final base = + baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final urls = [ + for (final id in albumIds) '$base/api/albums/$id/cover', + for (final id in playlistIds) '$base/api/playlists/$id/cover', + ]; + final mgr = DefaultCacheManager(); + var index = 0; + Future worker() async { + while (index < urls.length) { + final i = index++; + try { + await mgr.downloadFile(urls[i], authHeaders: headers); + } catch (_) { + // Best-effort prewarm — failures are expected for system + // playlists whose collage hasn't been built yet, and for + // any transient auth race. Don't surface or abort. + } + } + } + + await Future.wait(List.generate(3, (_) => worker())); + } + CachedArtistsCompanion _artistFromJson(Map j) => CachedArtistsCompanion.insert( id: j['id'] as String, diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index eff2b43a..097c6699 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -351,7 +351,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index e05cf6c4..9b80a395 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -37,6 +37,12 @@ dependencies: # by ServerImage (auth-aware path) and as CachedNetworkImageProvider # for the mini bar (which composes its own Image widget). cached_network_image: ^3.4.1 + # flutter_cache_manager is the disk-cache layer cached_network_image + # sits on top of. SyncController uses DefaultCacheManager directly to + # pre-warm covers during metadata sync, so a cold-start home grid + # paints from disk on the very first scroll rather than firing a + # network round-trip per tile. + flutter_cache_manager: ^3.4.1 dev_dependencies: flutter_test: