// 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 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: // - 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((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((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((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 ?? '', ); } });