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:
@@ -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()),
|
||||
),
|
||||
]);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user