feat(flutter): per-item rendering infrastructure (Slice A)

Plumbing for the per-item rendering pass — no UI changes yet, just
the layers the home/playlist/liked migrations will sit on.

* CachedHomeIndex drift table (schema 5→6) — section/position →
  entity-id rows, populated by the upcoming /api/home/index endpoint.
* HydrationQueue (concurrency=4, in-flight dedup) — bounded request
  pump that takes (entityType, entityId) and persists the result to
  the right cached_<entity> table. Albums + artists wired today;
  tracks deferred until /api/tracks/:id exists.
* Per-entity tile providers (albumTileProvider, artistTileProvider,
  trackTileProvider as StreamProvider.family) — watch drift, enqueue
  hydration on miss, yield AsyncValue<EntityRef?>.
* Skeleton widgets (album/artist/track) matched to the real card
  dimensions with a 1.2s shimmer sweep using FabledSword tokens. No
  shimmer-package dep — single AnimationController per surface.

See docs/superpowers/specs/2026-05-13-per-item-rendering-design.md
for the full architecture rationale.
This commit is contained in:
2026-05-13 20:25:40 -04:00
parent 99462185b4
commit 64db364834
4 changed files with 490 additions and 1 deletions
+30 -1
View File
@@ -142,6 +142,27 @@ class CachedHistorySnapshot extends Table {
Set<Column> get primaryKey => {id};
}
/// Section→position→entity-id index for the home screen, populated
/// from the per-item discovery endpoint `/api/home/index`. Each row
/// pins one tile slot to an entity; the actual entity data lives in
/// cached_albums / cached_artists / cached_tracks. The home screen
/// reads this table to know the layout, then hydrates each tile
/// against the entity tables (per-item rendering). Schema 6+.
class CachedHomeIndex extends Table {
/// One of: 'recently_added_albums', 'rediscover_albums',
/// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
/// Mirrors the keys /api/home and /api/home/index emit.
TextColumn get section => text()();
IntColumn get position => integer()();
/// One of: 'album', 'artist', 'track'. Used to dispatch hydration
/// to the right per-entity endpoint.
TextColumn get entityType => text()();
TextColumn get entityId => text()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {section, position};
}
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
/// /api/quarantine/mine — fully denormalized (track + album + artist
/// fields inline) because the Hidden tab renders straight from this row
@@ -179,12 +200,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
CachedHomeSnapshot,
CachedHistorySnapshot,
CachedQuarantineMine,
CachedHomeIndex,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 5;
int get schemaVersion => 6;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -218,6 +240,13 @@ class AppDb extends _$AppDb {
// populates. Optimistic flag/unflag also writes here.
await m.createTable(cachedQuarantineMine);
}
if (from < 6) {
// Schema 6: cached_home_index for per-item rendering.
// Empty on upgrade; first /api/home/index fetch populates.
// cached_home_snapshot stays in place for now — older
// builds still read from it as a fallback path.
await m.createTable(cachedHomeIndex);
}
},
);
}
+137
View File
@@ -0,0 +1,137 @@
// 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 '../api/endpoints/library.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/db.dart';
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':
// TODO(per-item slice B): GET /api/tracks/:id doesn't exist
// yet; tracks remain bulk-loaded until that endpoint lands.
// Tile providers for tracks read drift but never enqueue.
break;
}
} 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());
}
}
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);
});
+124
View File
@@ -0,0 +1,124 @@
// 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 '../cache/db.dart';
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]. No hydration today — see
/// hydration_queue.dart's `case 'track':` note. The tile renders a
/// skeleton while drift is empty; once sync populates the row (or a
/// future GET /api/tracks/:id endpoint enables real hydration), the
/// stream emits the populated TrackRef.
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)),
]);
await for (final rows in query.watch()) {
if (rows.isEmpty) {
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 ?? '',
);
}
});
@@ -0,0 +1,199 @@
// Skeleton placeholder widgets for the per-item rendering architecture
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
//
// Each skeleton matches the exact dimensions of its corresponding real
// card so the layout doesn't shift when content lands. A subtle
// shimmer sweep makes the page feel alive while individual tiles
// hydrate against drift / REST in the background.
//
// Self-contained shimmer (no `shimmer` package dep) so we can keep
// pubspec lean and tune the sweep colors against FabledSword tokens.
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Period of one full shimmer sweep across the skeleton.
const Duration _shimmerPeriod = Duration(milliseconds: 1200);
/// Wraps a child with a slow-moving highlight band on top of the
/// skeleton's base color. Cheap — uses a single AnimationController
/// per surface and a LinearGradient shader.
class _Shimmer extends StatefulWidget {
const _Shimmer({required this.child});
final Widget child;
@override
State<_Shimmer> createState() => _ShimmerState();
}
class _ShimmerState extends State<_Shimmer>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController(
vsync: this,
duration: _shimmerPeriod,
)..repeat();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (rect) {
// Sweep starts off-screen left, ends off-screen right.
final dx = (_ctrl.value * 2 - 1) * rect.width;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
fs.slate,
fs.iron,
fs.slate,
],
stops: const [0.0, 0.5, 1.0],
transform: _SlideGradient(dx),
).createShader(rect);
},
child: child,
);
},
child: widget.child,
);
}
}
/// Helper for translating a gradient horizontally inside its rect.
class _SlideGradient extends GradientTransform {
const _SlideGradient(this.dx);
final double dx;
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(dx, 0, 0);
}
}
/// Skeleton matched to AlbumCard's 140px outer width / 124px cover /
/// title + artist text rows. Used in horizontal carousels where the
/// real card lives.
class SkeletonAlbumTile extends StatelessWidget {
const SkeletonAlbumTile({super.key, this.width = 140});
final double width;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return _Shimmer(
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: coverSize,
height: coverSize,
color: fs.slate,
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.8, height: 14, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.6, height: 12, color: fs.slate),
],
),
),
),
);
}
}
/// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape.
class SkeletonArtistTile extends StatelessWidget {
const SkeletonArtistTile({super.key, this.width = 140});
final double width;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return _Shimmer(
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
shape: BoxShape.circle,
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 14, color: fs.slate),
],
),
),
),
);
}
}
/// Skeleton matched to TrackRow's vertical-list layout. 56dp cover +
/// title + artist line, similar to the real row.
class SkeletonTrackRow extends StatelessWidget {
const SkeletonTrackRow({super.key});
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return _Shimmer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 180, height: 14, color: fs.slate),
const SizedBox(height: 6),
Container(width: 120, height: 12, color: fs.slate),
],
),
),
],
),
),
);
}
}