feat(flutter): SWR everywhere + nav hydration + cold-start home skeleton
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.
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
import '../cache/audio_cache_manager.dart';
|
import '../cache/audio_cache_manager.dart';
|
||||||
import '../cache/db.dart';
|
import '../cache/db.dart';
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
@@ -12,18 +13,78 @@ import 'library_providers.dart';
|
|||||||
import 'widgets/track_row.dart';
|
import 'widgets/track_row.dart';
|
||||||
|
|
||||||
class AlbumDetailScreen extends ConsumerWidget {
|
class AlbumDetailScreen extends ConsumerWidget {
|
||||||
const AlbumDetailScreen({required this.id, super.key});
|
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: ref.watch(albumProvider(id)).when(
|
body: live.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
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: [
|
data: (r) => ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
@@ -12,22 +12,49 @@ import 'library_providers.dart';
|
|||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
|
|
||||||
class ArtistDetailScreen extends ConsumerWidget {
|
class ArtistDetailScreen extends ConsumerWidget {
|
||||||
const ArtistDetailScreen({required this.id, super.key});
|
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional artist reference from the caller. Lets the screen render
|
||||||
|
/// the name + avatar immediately while albums load.
|
||||||
|
final ArtistRef? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artist = ref.watch(artistProvider(id));
|
final artist = ref.watch(artistProvider(id));
|
||||||
final albums = ref.watch(artistAlbumsProvider(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(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: artist.when(
|
body: artist.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
// While loading: render header from seed if available so the
|
||||||
data: (a) => ListView(children: [
|
// 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<List<AlbumRef>> albums,
|
||||||
|
FabledSwordTheme fs,
|
||||||
|
) =>
|
||||||
|
ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
@@ -111,16 +138,14 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
width: cellW,
|
width: cellW,
|
||||||
titleMaxLines: 2,
|
titleMaxLines: 2,
|
||||||
showArtist: false,
|
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
|
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import '../models/track.dart';
|
|||||||
import '../playlists/playlists_provider.dart';
|
import '../playlists/playlists_provider.dart';
|
||||||
import '../playlists/widgets/playlist_card.dart';
|
import '../playlists/widgets/playlist_card.dart';
|
||||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||||
import '../shared/delayed_loading.dart';
|
|
||||||
import '../shared/widgets/connection_error_banner.dart';
|
import '../shared/widgets/connection_error_banner.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
@@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||||
},
|
},
|
||||||
loading: () => const DelayedLoading(
|
loading: () => _HomeSkeleton(fs: fs),
|
||||||
isLoading: true,
|
|
||||||
whenReady: SizedBox.shrink(),
|
|
||||||
whileDelayed:
|
|
||||||
Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
data: (h) => RefreshIndicator(
|
data: (h) => RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -190,7 +184,7 @@ class _RecentlyAddedSection extends StatelessWidget {
|
|||||||
.map((a) => AlbumCard(
|
.map((a) => AlbumCard(
|
||||||
album: a,
|
album: a,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_push(context, '/albums/${a.id}'),
|
context.push('/albums/${a.id}', extra: a),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@@ -220,7 +214,7 @@ class _RediscoverSection extends StatelessWidget {
|
|||||||
.map((a) => AlbumCard(
|
.map((a) => AlbumCard(
|
||||||
album: a,
|
album: a,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_push(context, '/albums/${a.id}'),
|
context.push('/albums/${a.id}', extra: a),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@@ -232,7 +226,7 @@ class _RediscoverSection extends StatelessWidget {
|
|||||||
.map((ar) => ArtistCard(
|
.map((ar) => ArtistCard(
|
||||||
artist: ar,
|
artist: ar,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_push(context, '/artists/${ar.id}'),
|
context.push('/artists/${ar.id}', extra: ar),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
@@ -291,7 +285,8 @@ class _LastPlayedSection extends StatelessWidget {
|
|||||||
children: artists
|
children: artists
|
||||||
.map((ar) => ArtistCard(
|
.map((ar) => ArtistCard(
|
||||||
artist: ar,
|
artist: ar,
|
||||||
onTap: () => _push(context, '/artists/${ar.id}'),
|
onTap: () =>
|
||||||
|
context.push('/artists/${ar.id}', extra: ar),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
@@ -324,8 +319,87 @@ class _EmptySection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _push(BuildContext context, String path) {
|
/// Cold-start skeleton. Renders the same shape as the real home (a few
|
||||||
context.push(path);
|
/// 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<List<T>> _chunk<T>(List<T> items, int size) {
|
List<List<T>> _chunk<T>(List<T> items, int size) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
@@ -64,6 +66,9 @@ final artistProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.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)',
|
tag: 'artist($id)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -95,6 +100,7 @@ final artistAlbumsProvider =
|
|||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
tag: 'artistAlbums($artistId)',
|
tag: 'artistAlbums($artistId)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -128,8 +134,11 @@ final artistTracksProvider =
|
|||||||
albumTitle: album?.title ?? '',
|
albumTitle: album?.title ?? '',
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
isOnline: () async =>
|
isOnline: () async => (await ref
|
||||||
(await ref.read(connectivityProvider.future)),
|
.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
|
// 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).
|
// server genuinely returns zero tracks (or if the fetch fails).
|
||||||
var fetchAttempted = false;
|
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<bool> isOnline() async {
|
Future<bool> isOnline() async {
|
||||||
try {
|
try {
|
||||||
@@ -290,6 +303,16 @@ final albumProvider = StreamProvider.family<
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).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);
|
yield (album: album, tracks: tracks);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -123,29 +125,45 @@ final playlistDetailProvider =
|
|||||||
tracks: const [],
|
tracks: const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<bool> isOnline() async {
|
||||||
|
try {
|
||||||
|
return await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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()) {
|
await for (final playlistRows in playlistQuery.watch()) {
|
||||||
if (playlistRows.isEmpty) {
|
if (playlistRows.isEmpty) {
|
||||||
if (await ref.read(connectivityProvider.future)) {
|
if (await isOnline()) {
|
||||||
try {
|
try {
|
||||||
final api = await ref.read(playlistsApiProvider.future);
|
await fetchAndPopulate();
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// watch() re-emits next iteration with populated row.
|
// watch() re-emits next iteration with populated row.
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
yield emptyDetail();
|
yield emptyDetail();
|
||||||
@@ -177,6 +195,16 @@ final playlistDetailProvider =
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
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((_) {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '../auth/login_screen.dart';
|
|||||||
import '../auth/server_url_screen.dart';
|
import '../auth/server_url_screen.dart';
|
||||||
import '../library/album_detail_screen.dart';
|
import '../library/album_detail_screen.dart';
|
||||||
import '../library/artist_detail_screen.dart';
|
import '../library/artist_detail_screen.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
import '../discover/discover_screen.dart';
|
import '../discover/discover_screen.dart';
|
||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../library/library_screen.dart';
|
import '../library/library_screen.dart';
|
||||||
@@ -89,11 +91,19 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/artists/:id',
|
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(
|
GoRoute(
|
||||||
path: '/albums/:id',
|
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: '/queue', builder: (_, __) => const QueueScreen()),
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
|
|||||||
Reference in New Issue
Block a user