feat(flutter): pre-warm covers during library sync

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.
This commit is contained in:
2026-05-13 18:06:53 -04:00
parent f732c49645
commit 32c8d4f28f
3 changed files with 79 additions and 3 deletions
+72 -2
View File
@@ -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<SyncResult?> {
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 = <String>[];
final playlistCoverIds = <String>[];
await db.transaction(() async {
// ---- Upserts ----
@@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
upsertCount++;
}
for (final a in (upserts['album'] as List? ?? const [])) {
final m = a as Map<String, dynamic>;
await db.into(db.cachedAlbums).insertOnConflictUpdate(
_albumFromJson(a as Map<String, dynamic>),
_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<SyncResult?> {
upsertCount++;
}
for (final p in (upserts['playlist'] as List? ?? const [])) {
final m = p as Map<String, dynamic>;
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
_playlistFromJson(p as Map<String, dynamic>),
_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<SyncResult?> {
);
});
// 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<SyncResult?> {
}
}
/// 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<void> _prewarmCovers(
List<String> albumIds,
List<String> 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'}
: <String, String>{};
final base =
baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final urls = <String>[
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<void> 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<String, dynamic> j) =>
CachedArtistsCompanion.insert(
id: j['id'] as String,
+1 -1
View File
@@ -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"
+6
View File
@@ -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: