From 64db36483423908c7561c9289b30353f34e1b8bb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 20:25:40 -0400 Subject: [PATCH] feat(flutter): per-item rendering infrastructure (Slice A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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. * 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. --- flutter_client/lib/cache/db.dart | 31 ++- flutter_client/lib/cache/hydration_queue.dart | 137 ++++++++++++ flutter_client/lib/cache/tile_providers.dart | 124 +++++++++++ .../lib/shared/widgets/skeletons.dart | 199 ++++++++++++++++++ 4 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 flutter_client/lib/cache/hydration_queue.dart create mode 100644 flutter_client/lib/cache/tile_providers.dart create mode 100644 flutter_client/lib/shared/widgets/skeletons.dart diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index f5ef32b8..d2227677 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -142,6 +142,27 @@ class CachedHistorySnapshot extends Table { Set 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 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); + } }, ); } diff --git a/flutter_client/lib/cache/hydration_queue.dart b/flutter_client/lib/cache/hydration_queue.dart new file mode 100644 index 00000000..0456fcae --- /dev/null +++ b/flutter_client/lib/cache/hydration_queue.dart @@ -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//: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 `:`) 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 _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 _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 _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 _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((ref) { + return HydrationQueue(ref); +}); diff --git a/flutter_client/lib/cache/tile_providers.dart b/flutter_client/lib/cache/tile_providers.dart new file mode 100644 index 00000000..f4f9dab9 --- /dev/null +++ b/flutter_client/lib/cache/tile_providers.dart @@ -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 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 '../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((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]. 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((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 ?? '', + ); + } +}); diff --git a/flutter_client/lib/shared/widgets/skeletons.dart b/flutter_client/lib/shared/widgets/skeletons.dart new file mode 100644 index 00000000..edbbbbf8 --- /dev/null +++ b/flutter_client/lib/shared/widgets/skeletons.dart @@ -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()!; + 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()!; + 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()!; + 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()!; + 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), + ], + ), + ), + ], + ), + ), + ); + } +}