From 11c40c6aca3e801b757f77f1b51d6c3b249f3e94 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 12:12:44 -0400 Subject: [PATCH] feat(flutter): SWR everywhere + nav hydration + cold-start home skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes addressing the cold-start spinner + stale-on-revisit pain. A. SWR on remaining cacheFirst providers - artistProvider, artistAlbumsProvider, artistTracksProvider all gain alwaysRefresh: true. Cache hit still renders instantly; one background refresh per subscription keeps the row from going stale forever. - albumProvider (inline async*) and playlistDetailProvider (inline async*) now keep a `revalidated` flag and kick a one-shot background fetch on the first complete cache hit. Same effect as cacheFirst's alwaysRefresh, just inline. - Three providers gained the connectivity timeout that album/artist/playlist already had. B. Navigation hydration - AlbumDetailScreen accepts `AlbumRef? seed`; ArtistDetailScreen accepts `ArtistRef? seed`. When the live provider is still loading, the seed populates cover/title/artist immediately so the page isn't blank. - Routing wires `extra: AlbumRef|ArtistRef` from go_router into the seed parameter. - Call sites updated: home (Recently added, Rediscover albums + artists, Last played), artist detail album grid. Where a ref isn't available (track actions sheet), the screen falls back to the spinner — no regression. C. Cold-start home skeleton - Replace the full-screen CircularProgressIndicator on /home with a layout-preserving skeleton: 5 section titles + 6 grey card-shaped placeholders per row. The page feels populated immediately; sections fill in independently as data arrives via the per-section providers. - Drops the unused DelayedLoading import. Net effect: re-visits to detail screens render instantly (cache hit + silent refresh); first visit from a tile shows the seed header immediately while tracks load; cold-start home shows a layout skeleton instead of a 30s blank spinner. --- .../lib/library/album_detail_screen.dart | 67 +++++++++++- .../lib/library/artist_detail_screen.dart | 41 +++++-- flutter_client/lib/library/home_screen.dart | 100 +++++++++++++++--- .../lib/library/library_providers.dart | 27 ++++- .../lib/playlists/playlists_provider.dart | 68 ++++++++---- flutter_client/lib/shared/routing.dart | 14 ++- 6 files changed, 269 insertions(+), 48 deletions(-) diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart index a7bc694a..8e11844e 100644 --- a/flutter_client/lib/library/album_detail_screen.dart +++ b/flutter_client/lib/library/album_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/likes.dart'; +import '../models/album.dart'; import '../cache/audio_cache_manager.dart'; import '../cache/db.dart'; import '../likes/like_button.dart'; @@ -12,18 +13,78 @@ import 'library_providers.dart'; import 'widgets/track_row.dart'; class AlbumDetailScreen extends ConsumerWidget { - const AlbumDetailScreen({required this.id, super.key}); + const AlbumDetailScreen({required this.id, this.seed, super.key}); final String id; + /// Optional album reference passed by the caller (typically the + /// tile they tapped) so the header can render immediately while + /// the full provider loads tracks. Hydration only — provider data + /// still wins once it arrives. + final AlbumRef? seed; + @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; + final live = ref.watch(albumProvider(id)); + + // Pick the best header info available: live data > seed > nothing. + final headerTitle = live.value?.album.title.isNotEmpty == true + ? live.value!.album.title + : seed?.title ?? ''; + final headerArtist = live.value?.album.artistName.isNotEmpty == true + ? live.value!.album.artistName + : seed?.artistName ?? ''; + + Widget header() => Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 96, + height: 96, + child: ServerImage( + url: '/api/albums/$id/cover', + fit: BoxFit.cover, + fallback: Container(color: fs.slate), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headerTitle, + style: TextStyle( + color: fs.parchment, + fontFamily: fs.display.fontFamily, + fontSize: 22, + ), + ), + if (headerArtist.isNotEmpty) + Text(headerArtist, style: TextStyle(color: fs.ash)), + ], + ), + ), + ]), + ); + return Scaffold( appBar: AppBar(), backgroundColor: fs.obsidian, - body: ref.watch(albumProvider(id)).when( + body: live.when( error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => seed != null + ? ListView(children: [ + header(), + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ), + ]) + : const Center(child: CircularProgressIndicator()), data: (r) => ListView(children: [ Padding( padding: const EdgeInsets.all(16), diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart index 497d24a8..c2123039 100644 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ b/flutter_client/lib/library/artist_detail_screen.dart @@ -12,22 +12,49 @@ import 'library_providers.dart'; import 'widgets/album_card.dart'; class ArtistDetailScreen extends ConsumerWidget { - const ArtistDetailScreen({required this.id, super.key}); + const ArtistDetailScreen({required this.id, this.seed, super.key}); final String id; + /// Optional artist reference from the caller. Lets the screen render + /// the name + avatar immediately while albums load. + final ArtistRef? seed; + @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; final artist = ref.watch(artistProvider(id)); final albums = ref.watch(artistAlbumsProvider(id)); + // Resolve which artist info to render in the header. Live wins + // when present and populated; seed fills the gap during the + // first frame after navigation. + final liveArtist = artist.value; + final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty; + final effective = hasLiveName ? liveArtist : (seed ?? liveArtist); + return Scaffold( appBar: AppBar(), backgroundColor: fs.obsidian, body: artist.when( error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Center(child: CircularProgressIndicator()), - data: (a) => ListView(children: [ + // While loading: render header from seed if available so the + // page isn't blank. + loading: () => seed == null + ? const Center(child: CircularProgressIndicator()) + : _artistBody(context, ref, seed!, albums, fs), + data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs), + ), + ); + } + + Widget _artistBody( + BuildContext context, + WidgetRef ref, + ArtistRef a, + AsyncValue> albums, + FabledSwordTheme fs, + ) => + ListView(children: [ Padding( padding: const EdgeInsets.all(16), child: Row(children: [ @@ -111,16 +138,14 @@ class ArtistDetailScreen extends ConsumerWidget { width: cellW, titleMaxLines: 2, showArtist: false, - onTap: () => context.push('/albums/${album.id}'), + onTap: () => + context.push('/albums/${album.id}', extra: album), ); }, ); }), ), - ]), - ), - ); - } + ]); } /// Renders the artist's avatar. Server-emitted coverUrl wins when diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index cbf87bd7..bf455251 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -12,7 +12,6 @@ import '../models/track.dart'; import '../playlists/playlists_provider.dart'; import '../playlists/widgets/playlist_card.dart'; import '../playlists/widgets/playlist_placeholder_card.dart'; -import '../shared/delayed_loading.dart'; import '../shared/widgets/connection_error_banner.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; @@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget { } return Center(child: Text('$e', style: TextStyle(color: fs.error))); }, - loading: () => const DelayedLoading( - isLoading: true, - whenReady: SizedBox.shrink(), - whileDelayed: - Center(child: CircularProgressIndicator()), - ), + loading: () => _HomeSkeleton(fs: fs), data: (h) => RefreshIndicator( onRefresh: () async => ref.refresh(homeProvider.future), child: ListView( @@ -190,7 +184,7 @@ class _RecentlyAddedSection extends StatelessWidget { .map((a) => AlbumCard( album: a, onTap: () => - _push(context, '/albums/${a.id}'), + context.push('/albums/${a.id}', extra: a), )) .toList(), ), @@ -220,7 +214,7 @@ class _RediscoverSection extends StatelessWidget { .map((a) => AlbumCard( album: a, onTap: () => - _push(context, '/albums/${a.id}'), + context.push('/albums/${a.id}', extra: a), )) .toList(), ), @@ -232,7 +226,7 @@ class _RediscoverSection extends StatelessWidget { .map((ar) => ArtistCard( artist: ar, onTap: () => - _push(context, '/artists/${ar.id}'), + context.push('/artists/${ar.id}', extra: ar), )) .toList(), ), @@ -291,7 +285,8 @@ class _LastPlayedSection extends StatelessWidget { children: artists .map((ar) => ArtistCard( artist: ar, - onTap: () => _push(context, '/artists/${ar.id}'), + onTap: () => + context.push('/artists/${ar.id}', extra: ar), )) .toList(), ); @@ -324,8 +319,87 @@ class _EmptySection extends StatelessWidget { } } -void _push(BuildContext context, String path) { - context.push(path); +/// Cold-start skeleton. Renders the same shape as the real home (a few +/// section titles + card-sized placeholders) so the page feels alive +/// while /api/home is in flight, instead of a 30-second blank spinner. +/// Each section drops in independently as data arrives via the +/// individual providers. +class _HomeSkeleton extends StatelessWidget { + const _HomeSkeleton({required this.fs}); + final FabledSwordTheme fs; + + @override + Widget build(BuildContext context) { + return ListView( + physics: const ClampingScrollPhysics(), + children: [ + _SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176), + _SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140), + _SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140), + _SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140), + _SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140), + const SizedBox(height: 140), + ], + ); + } +} + +class _SkeletonSection extends StatelessWidget { + const _SkeletonSection({ + required this.fs, + required this.title, + required this.cardWidth, + }); + final FabledSwordTheme fs; + final String title; + final double cardWidth; + + @override + Widget build(BuildContext context) { + final coverSize = cardWidth - 16; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + title, + style: TextStyle( + fontFamily: fs.display.fontFamily, + fontSize: 18, + color: fs.parchment, + ), + ), + ), + SizedBox( + height: coverSize + 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + physics: const NeverScrollableScrollPhysics(), + itemCount: 6, + itemBuilder: (_, __) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: coverSize, + height: coverSize, + decoration: BoxDecoration( + color: fs.slate, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 8), + Container(width: coverSize * 0.7, height: 12, color: fs.slate), + const SizedBox(height: 4), + Container(width: coverSize * 0.4, height: 10, color: fs.iron), + ], + ), + ), + ), + ), + ]); + } } List> _chunk(List items, int size) { diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index 25fbec9b..202b5bf4 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:drift/drift.dart' as drift; import 'package:flutter/foundation.dart' show debugPrint; @@ -64,6 +66,9 @@ final artistProvider = isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), + // SWR: yield cache instantly on hit, refresh in the background so + // the row catches up to server state without making the user wait. + alwaysRefresh: true, tag: 'artist($id)', ); }); @@ -95,6 +100,7 @@ final artistAlbumsProvider = isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), + alwaysRefresh: true, tag: 'artistAlbums($artistId)', ); }); @@ -128,8 +134,11 @@ final artistTracksProvider = albumTitle: album?.title ?? '', ); }).toList(), - isOnline: () async => - (await ref.read(connectivityProvider.future)), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + alwaysRefresh: true, + tag: 'artistTracks($artistId)', ); }); @@ -160,6 +169,10 @@ final albumProvider = StreamProvider.family< // Once-per-subscription guard so we don't re-fetch in a loop if the // server genuinely returns zero tracks (or if the fetch fails). var fetchAttempted = false; + // SWR revalidation guard — fire one background refresh per + // subscription on the first complete cache hit, so the displayed + // data catches up to server state without making the user wait. + var revalidated = false; Future isOnline() async { try { @@ -290,6 +303,16 @@ final albumProvider = StreamProvider.family< ); }).toList(); + // SWR: first complete cache hit triggers one background refresh + // so we don't show stale data indefinitely. Drift watch re-emits + // on success and the next iteration yields the fresh data. + if (!revalidated && trackRows.isNotEmpty) { + revalidated = true; + if (await isOnline()) { + unawaited(fetchAndPopulate()); + } + } + yield (album: album, tracks: tracks); } }); diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index 4bc23268..6507fd87 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:drift/drift.dart' as drift; import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -123,29 +125,45 @@ final playlistDetailProvider = tracks: const [], ); + Future isOnline() async { + try { + return await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true); + } catch (_) { + return true; + } + } + + Future fetchAndPopulate() async { + final api = await ref.read(playlistsApiProvider.future); + final fresh = await api.get(id); + await db.batch((b) { + b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), + mode: drift.InsertMode.insertOrReplace); + for (var i = 0; i < fresh.tracks.length; i++) { + final t = fresh.tracks[i]; + if (t.trackId == null) continue; + b.insert( + db.cachedPlaylistTracks, + CachedPlaylistTracksCompanion.insert( + playlistId: id, + trackId: t.trackId!, + position: drift.Value(i), + ), + mode: drift.InsertMode.insertOrReplace, + ); + } + }); + } + + var revalidated = false; + await for (final playlistRows in playlistQuery.watch()) { if (playlistRows.isEmpty) { - if (await ref.read(connectivityProvider.future)) { + if (await isOnline()) { try { - final api = await ref.read(playlistsApiProvider.future); - final fresh = await api.get(id); - await db.batch((b) { - b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), - mode: drift.InsertMode.insertOrReplace); - for (var i = 0; i < fresh.tracks.length; i++) { - final t = fresh.tracks[i]; - if (t.trackId == null) continue; - b.insert( - db.cachedPlaylistTracks, - CachedPlaylistTracksCompanion.insert( - playlistId: id, - trackId: t.trackId!, - position: drift.Value(i), - ), - mode: drift.InsertMode.insertOrReplace, - ); - } - }); + await fetchAndPopulate(); // watch() re-emits next iteration with populated row. } catch (_) { yield emptyDetail(); @@ -177,6 +195,16 @@ final playlistDetailProvider = }).toList(); yield PlaylistDetail(playlist: playlist, tracks: tracks); + + // SWR: first complete cache hit kicks one background refresh so + // the playlist catches up to server state (new tracks, renames, + // etc.) without making the user wait. + if (!revalidated) { + revalidated = true; + if (await isOnline()) { + unawaited(fetchAndPopulate().catchError((_) {})); + } + } } }); diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 9d8b55fe..532cf1e3 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -7,6 +7,8 @@ import '../auth/login_screen.dart'; import '../auth/server_url_screen.dart'; import '../library/album_detail_screen.dart'; import '../library/artist_detail_screen.dart'; +import '../models/album.dart'; +import '../models/artist.dart'; import '../discover/discover_screen.dart'; import '../library/home_screen.dart'; import '../library/library_screen.dart'; @@ -89,11 +91,19 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute( path: '/artists/:id', - builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!), + // `extra` carries an optional ArtistRef so the detail + // header renders immediately while tracks/albums load. + builder: (_, s) => ArtistDetailScreen( + id: s.pathParameters['id']!, + seed: s.extra is ArtistRef ? s.extra as ArtistRef : null, + ), ), GoRoute( path: '/albums/:id', - builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!), + builder: (_, s) => AlbumDetailScreen( + id: s.pathParameters['id']!, + seed: s.extra is AlbumRef ? s.extra as AlbumRef : null, + ), ), GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),