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