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(
// Loading from a seed: render the header immediately + a
// small inline "loading tracks" hint, instead of an opaque
// full-screen spinner. The body fills in when tracks arrive.
loading: () => seed == null
? const Center(child: CircularProgressIndicator())
: _SeedBody(playlist: seed!),
// Loading from a seed: render the header immediately + the
// exact track-count of skeleton rows, instead of an opaque
// full-screen spinner. Cold-visit feel: shaped page with
// shimmering rows that swap in for real ones as the bulk
// detail fetch lands.
loading: () => _SkeletonBody(seed: seed),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (d) => _Body(detail: d),
@@ -82,40 +82,83 @@ class PlaylistDetailScreen extends ConsumerWidget {
}
}
/// Loading-state body: renders just the header from the seed
/// playlist + a "loading tracks…" placeholder. CTA buttons are
/// hidden until the real track list arrives (they need playable
/// refs to do anything useful).
class _SeedBody extends StatelessWidget {
const _SeedBody({required this.playlist});
final Playlist playlist;
/// Cold-visit body: header from the seed (if any) + N skeleton rows.
/// N comes from the seed's trackCount so the row count matches the
/// real list when it lands — no layout jump on swap. Without a seed
/// (deep link straight to a playlist with no prior cache) the
/// skeleton renders a small default and grows when real data arrives.
class _SkeletonBody extends StatelessWidget {
const _SkeletonBody({this.seed});
final Playlist? seed;
static const _defaultSkeletonCount = 8;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListView(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (playlist.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
playlist.description,
style: TextStyle(color: fs.ash, fontSize: 13),
),
final count = seed?.trackCount ?? _defaultSkeletonCount;
return ListView.builder(
itemCount: count + 1, // +1 for header
itemBuilder: (ctx, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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()),
),
]);
);
}
}