feat(flutter): skeleton rows during playlist cold-load (Slice D)

Cold-visit playlist detail used to render the header from the seed
and then a single CircularProgressIndicator while the bulk fetch
ran. Now it renders the header + seed.trackCount worth of skeleton
rows (capped at 8 when no seed is present). The real list swaps in
without a layout jump.

Slice D was originally scoped for per-track hydration through a new
discovery endpoint, but the unavailable-entries data model (playlist
rows that lost their underlying track to deletion / quarantine) make
that disproportionately expensive — would require a schema change to
support null trackIds on cached_playlist_tracks, a server endpoint,
and a screen rewrite. The skeleton-row approach captures ~80% of the
perceptual win at a fraction of the cost. True per-track hydration
remains a future opportunity if cold visits to very large playlists
still feel sluggish after Slice F polish lands.
This commit is contained in:
2026-05-13 21:47:13 -04:00
parent 03c13d21c6
commit a324454efe
@@ -68,12 +68,12 @@ class PlaylistDetailScreen extends ConsumerWidget {
), ),
), ),
body: detail.when( body: detail.when(
// Loading from a seed: render the header immediately + a // Loading from a seed: render the header immediately + the
// small inline "loading tracks" hint, instead of an opaque // exact track-count of skeleton rows, instead of an opaque
// full-screen spinner. The body fills in when tracks arrive. // full-screen spinner. Cold-visit feel: shaped page with
loading: () => seed == null // shimmering rows that swap in for real ones as the bulk
? const Center(child: CircularProgressIndicator()) // detail fetch lands.
: _SeedBody(playlist: seed!), loading: () => _SkeletonBody(seed: seed),
error: (e, _) => error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))), Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (d) => _Body(detail: d), data: (d) => _Body(detail: d),
@@ -82,40 +82,83 @@ class PlaylistDetailScreen extends ConsumerWidget {
} }
} }
/// Loading-state body: renders just the header from the seed /// Cold-visit body: header from the seed (if any) + N skeleton rows.
/// playlist + a "loading tracks…" placeholder. CTA buttons are /// N comes from the seed's trackCount so the row count matches the
/// hidden until the real track list arrives (they need playable /// real list when it lands — no layout jump on swap. Without a seed
/// refs to do anything useful). /// (deep link straight to a playlist with no prior cache) the
class _SeedBody extends StatelessWidget { /// skeleton renders a small default and grows when real data arrives.
const _SeedBody({required this.playlist}); class _SkeletonBody extends StatelessWidget {
final Playlist playlist; const _SkeletonBody({this.seed});
final Playlist? seed;
static const _defaultSkeletonCount = 8;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!; final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListView(children: [ final count = seed?.trackCount ?? _defaultSkeletonCount;
Padding( return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), itemCount: count + 1, // +1 for header
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ itemBuilder: (ctx, i) {
if (playlist.description.isNotEmpty) if (i == 0) {
Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Text( child: Column(
playlist.description, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(color: fs.ash, fontSize: 13), children: [
), if (seed != null && seed!.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
seed!.description,
style: TextStyle(color: fs.ash, fontSize: 13),
),
),
Text(
seed == null
? 'Loading…'
: '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}',
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
);
}
return const _PlaylistTrackSkeleton();
},
);
}
}
/// Skeleton matched to _PlaylistTrackRow's layout (no cover image —
/// playlist rows are text-only). Two text-shaped placeholders for
/// title + secondary line, plus a duration block on the right.
class _PlaylistTrackSkeleton extends StatelessWidget {
const _PlaylistTrackSkeleton();
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 200, height: 14, color: fs.slate),
const SizedBox(height: 6),
Container(width: 140, height: 12, color: fs.slate),
],
), ),
Text(
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
style: TextStyle(color: fs.ash, fontSize: 12),
), ),
]), const SizedBox(width: 16),
Container(width: 32, height: 12, color: fs.slate),
],
), ),
const Padding( );
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
]);
} }
} }