03c13d21c6
End-to-end pilot of the per-item architecture. Home now reads from the new homeIndexProvider (drift-first over CachedHomeIndex with /api/home/index discovery + SWR), then each tile is a small ConsumerWidget watching its own albumTileProvider/artistTileProvider/ trackTileProvider. Tiles render a matched-dimension skeleton while their entity is still hydrating, and swap in the real card once drift emits the populated row. Track hydration is wired up — /api/tracks/:id already existed so the queue's case 'track' just calls api.getTrack(id) and persists. The visible behavior: * Cold visit: small /api/home/index round-trip (IDs only, ~10× smaller than /api/home), then sections appear shaped with skeleton tiles; each tile materializes as the hydration queue drains. No more "30s blank → everything pops in at once." * Warm visit: drift index emits instantly, drift entity rows emit instantly, no network. Page paints fully in the first frame. * Mid-state: scrolling through a partially-hydrated section sees real cards next to skeleton cards. Layout doesn't shift because skeletons match real-card dimensions exactly. CachedHomeSnapshot (and the legacy homeProvider) stay in place but unconsumed by Flutter — left in for now so revert is cheap if the new path needs reworking. Cleanup follow-up in a later slice. Old /api/home endpoint untouched, so the web client keeps working unchanged.
140 lines
4.9 KiB
Dart
140 lines
4.9 KiB
Dart
// Per-item hydration coordinator for the home/playlist/liked surfaces.
|
|
//
|
|
// Tile widgets are reactive to their per-entity drift row. On first
|
|
// build, if drift has no row for the requested id, the tile asks this
|
|
// queue to hydrate it. The queue dispatches one /api/<type>/:id
|
|
// request, writes the result to drift, and the drift watch() on the
|
|
// tile re-emits with the populated row.
|
|
//
|
|
// Concurrency cap: HydrationQueue limits the number of in-flight
|
|
// requests so a 50-tile home screen doesn't fire 50 parallel /api/
|
|
// calls and stall the auth-token-bound dio pool. In-flight dedup
|
|
// (keyed by `<type>:<id>`) ensures a single hydration when multiple
|
|
// tiles share an entity (rare on home, common when several screens
|
|
// reference the same album).
|
|
//
|
|
// Failures are swallowed — a single failed hydration leaves the tile
|
|
// in skeleton state. A future retry-on-visit pass can layer on top
|
|
// without changing the queue's contract.
|
|
|
|
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../cache/adapters.dart';
|
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
|
import '../library/library_providers.dart' show libraryApiProvider;
|
|
|
|
/// Bounded-concurrency request queue for per-entity hydration. One
|
|
/// instance per Riverpod scope (provider singleton below).
|
|
class HydrationQueue {
|
|
HydrationQueue(this._ref);
|
|
final Ref _ref;
|
|
|
|
/// Concurrent slot count. 4 keeps the dio token pool from saturating
|
|
/// while still pulling tiles down faster than a serial loop. Bump
|
|
/// if profiling shows the queue idle while the network is healthy.
|
|
static const _maxConcurrent = 4;
|
|
|
|
final Queue<_HydrationRequest> _pending = Queue();
|
|
final Set<String> _inFlightKeys = {};
|
|
int _inFlight = 0;
|
|
|
|
/// Request hydration for (entityType, entityId). Idempotent — second
|
|
/// call for the same key while the first is queued or in flight is
|
|
/// dropped, so multiple tile rebuilds during a fast scroll don't
|
|
/// inflate the queue.
|
|
void enqueue({required String entityType, required String entityId}) {
|
|
if (entityId.isEmpty) return;
|
|
final key = '$entityType:$entityId';
|
|
if (_inFlightKeys.contains(key)) return;
|
|
if (_pending.any((r) => r.key == key)) return;
|
|
_pending.add(_HydrationRequest(
|
|
entityType: entityType, entityId: entityId, key: key));
|
|
_pump();
|
|
}
|
|
|
|
void _pump() {
|
|
while (_inFlight < _maxConcurrent && _pending.isNotEmpty) {
|
|
final req = _pending.removeFirst();
|
|
_inFlightKeys.add(req.key);
|
|
_inFlight++;
|
|
// Unawaited on purpose — _pump returns immediately so further
|
|
// enqueues can fill remaining slots while this one runs.
|
|
unawaited(_execute(req).whenComplete(() {
|
|
_inFlight--;
|
|
_inFlightKeys.remove(req.key);
|
|
_pump();
|
|
}));
|
|
}
|
|
}
|
|
|
|
Future<void> _execute(_HydrationRequest req) async {
|
|
try {
|
|
switch (req.entityType) {
|
|
case 'album':
|
|
await _hydrateAlbum(req.entityId);
|
|
case 'artist':
|
|
await _hydrateArtist(req.entityId);
|
|
case 'track':
|
|
await _hydrateTrack(req.entityId);
|
|
}
|
|
} catch (_) {
|
|
// Swallow — tile stays in skeleton. A retry-on-visit pass can
|
|
// layer on top later without changing this contract.
|
|
}
|
|
}
|
|
|
|
Future<void> _hydrateAlbum(String id) async {
|
|
final api = await _ref.read(libraryApiProvider.future);
|
|
final result = await api.getAlbum(id);
|
|
final db = _ref.read(appDbProvider);
|
|
// Album + tracks come bundled in the same response; persist both
|
|
// so opening the album detail later is also a drift hit.
|
|
await db.transaction(() async {
|
|
await db
|
|
.into(db.cachedAlbums)
|
|
.insertOnConflictUpdate(result.album.toDrift());
|
|
if (result.tracks.isNotEmpty) {
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList());
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _hydrateArtist(String id) async {
|
|
final api = await _ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtist(id);
|
|
final db = _ref.read(appDbProvider);
|
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
|
}
|
|
|
|
Future<void> _hydrateTrack(String id) async {
|
|
final api = await _ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getTrack(id);
|
|
final db = _ref.read(appDbProvider);
|
|
await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift());
|
|
}
|
|
}
|
|
|
|
class _HydrationRequest {
|
|
_HydrationRequest({
|
|
required this.entityType,
|
|
required this.entityId,
|
|
required this.key,
|
|
});
|
|
final String entityType;
|
|
final String entityId;
|
|
final String key;
|
|
}
|
|
|
|
/// Singleton queue scoped to the Riverpod container. Kept as a plain
|
|
/// Provider (not StateProvider) since the queue's internal state is
|
|
/// pump-driven, not reactively observed.
|
|
final hydrationQueueProvider = Provider<HydrationQueue>((ref) {
|
|
return HydrationQueue(ref);
|
|
});
|