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.
128 lines
4.0 KiB
Dart
128 lines
4.0 KiB
Dart
// Per-entity tile providers for the per-item rendering architecture
|
|
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
|
|
//
|
|
// Each provider is a StreamProvider.family<EntityRef?, String> keyed
|
|
// by entity id. The stream:
|
|
// 1. Watches the entity's drift row
|
|
// 2. Yields the populated row on every emission
|
|
// 3. Yields null while the row is absent (UI shows skeleton)
|
|
// 4. On the first missing-row emission, enqueues a hydration via
|
|
// HydrationQueue so the row eventually lands
|
|
//
|
|
// The tile widget reacts to AsyncValue<EntityRef?>:
|
|
// - loading or data:null → skeleton
|
|
// - data:non-null → real card
|
|
// - error → error placeholder
|
|
//
|
|
// Subscriptions are per-tile. A 50-tile home screen creates 50 drift
|
|
// subscriptions; drift handles this fine in practice but worth
|
|
// measuring if a future surface scales to hundreds.
|
|
|
|
import 'package:drift/drift.dart' show leftOuterJoin;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../cache/adapters.dart';
|
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
|
import '../models/album.dart';
|
|
import '../models/artist.dart';
|
|
import '../models/track.dart';
|
|
import 'hydration_queue.dart';
|
|
|
|
/// Watches the cached_albums row for [id]. Triggers background
|
|
/// hydration on miss; yields the row once it lands.
|
|
final albumTileProvider =
|
|
StreamProvider.family<AlbumRef?, String>((ref, id) async* {
|
|
if (id.isEmpty) {
|
|
yield null;
|
|
return;
|
|
}
|
|
final db = ref.watch(appDbProvider);
|
|
final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id)))
|
|
.join([
|
|
leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
]);
|
|
var enqueued = false;
|
|
await for (final rows in query.watch()) {
|
|
if (rows.isEmpty) {
|
|
if (!enqueued) {
|
|
enqueued = true;
|
|
ref
|
|
.read(hydrationQueueProvider)
|
|
.enqueue(entityType: 'album', entityId: id);
|
|
}
|
|
yield null;
|
|
continue;
|
|
}
|
|
final r = rows.first;
|
|
final album = r.readTable(db.cachedAlbums);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
yield album.toRef(artistName: artist?.name ?? '');
|
|
}
|
|
});
|
|
|
|
/// Watches the cached_artists row for [id]. Triggers background
|
|
/// hydration on miss; yields the row once it lands.
|
|
final artistTileProvider =
|
|
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
|
if (id.isEmpty) {
|
|
yield null;
|
|
return;
|
|
}
|
|
final db = ref.watch(appDbProvider);
|
|
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
|
|
var enqueued = false;
|
|
await for (final rows in query.watch()) {
|
|
if (rows.isEmpty) {
|
|
if (!enqueued) {
|
|
enqueued = true;
|
|
ref
|
|
.read(hydrationQueueProvider)
|
|
.enqueue(entityType: 'artist', entityId: id);
|
|
}
|
|
yield null;
|
|
continue;
|
|
}
|
|
yield rows.first.toRef();
|
|
}
|
|
});
|
|
|
|
/// Watches the cached_tracks row for [id]. Triggers background
|
|
/// hydration on miss; yields the row once it lands.
|
|
final trackTileProvider =
|
|
StreamProvider.family<TrackRef?, String>((ref, id) async* {
|
|
if (id.isEmpty) {
|
|
yield null;
|
|
return;
|
|
}
|
|
final db = ref.watch(appDbProvider);
|
|
final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id)))
|
|
.join([
|
|
leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
|
leftOuterJoin(db.cachedAlbums,
|
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
|
]);
|
|
var enqueued = false;
|
|
await for (final rows in query.watch()) {
|
|
if (rows.isEmpty) {
|
|
if (!enqueued) {
|
|
enqueued = true;
|
|
ref
|
|
.read(hydrationQueueProvider)
|
|
.enqueue(entityType: 'track', entityId: id);
|
|
}
|
|
yield null;
|
|
continue;
|
|
}
|
|
final r = rows.first;
|
|
final t = r.readTable(db.cachedTracks);
|
|
final a = r.readTableOrNull(db.cachedArtists);
|
|
final al = r.readTableOrNull(db.cachedAlbums);
|
|
yield t.toRef(
|
|
artistName: a?.name ?? '',
|
|
albumTitle: al?.title ?? '',
|
|
);
|
|
}
|
|
});
|