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:
+72
-2
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user