diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index 1e0b0fbb..9b4262a7 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -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()!; - 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()!; + 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()), - ), - ]); + ); } }