// 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' as drift show OrderingTerm; 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. /// /// LEFT JOIN cached_albums ordered by sort_title so the first row per /// artist carries a representative album id; toRef() reconstructs /// `/api/albums//cover` from it. Without this join the ArtistRef /// has an empty coverUrl and tiles fall back to the music-notes /// placeholder even though the server has the cover available. 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))) .join([ leftOuterJoin(db.cachedAlbums, db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), ]) ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); 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; } // First row has the alphabetically-first album (or null if artist // has no albums in drift yet — yields a coverless ArtistRef which // the UI falls back to the placeholder icon for). final artist = rows.first.readTable(db.cachedArtists); final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); yield artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); } }); /// 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 ?? '', ); } });