feat(flutter): drift-first Library tabs + systemPlaylistsStatus + tab pre-warm
Four-part change to push more surfaces onto the drift cache and eliminate cold-tab-visit latency on the Library screen. * **Library Artists tab** — _libraryArtistsProvider migrates from REST-paginated AsyncNotifier with infinite-scroll loadMore to a drift-first StreamProvider over cached_artists ordered by sortName. Sync already populates the full set; cacheFirst's fetchAndPopulate covers the fresh-install + sync-not-yet-done cold case via /api/artists?limit=1000. SWR refresh on every visit. GridView.builder lazily realizes only visible cells so loading the full list up front is fine for typical libraries. loadMore + NotificationListener gone. * **Library Albums tab** — same migration, drift-first over cached_albums joined with cached_artists for the artistName field. * **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot) for the home Playlists row's "building / pending / failed" placeholder logic. Drift-first means the row paints with the prior status instantly instead of flickering through SystemPlaylistsStatus.empty() while the REST call resolves. * **Library screen tab pre-warm** — ref.listen on all 5 tab providers in _LibraryScreenState.build subscribes them upfront so swiping between tabs feels instant rather than each tab paying its own cold-cache cost on first visit. cacheFirst handles dedupe of concurrent fetchAndPopulate triggers. Test mock updated for the StreamProvider type change on systemPlaylistsStatusProvider.
This commit is contained in:
Vendored
+24
-1
@@ -142,6 +142,21 @@ class CachedHistorySnapshot extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Single-row cache of the /api/me/system-playlists-status response.
|
||||||
|
/// The home Playlists row reads this every render to pick between
|
||||||
|
/// real cards and placeholder cards for For-You / Discover / Songs-
|
||||||
|
/// like slots, so a fresh-mount cold fetch produces a visible
|
||||||
|
/// "building / pending / failed" flicker. Storing the last result
|
||||||
|
/// as a JSON blob means the home renders with the prior status
|
||||||
|
/// instantly, then SWR refreshes underneath. Schema 7+.
|
||||||
|
class CachedSystemPlaylistsStatus extends Table {
|
||||||
|
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||||
|
TextColumn get json => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
/// Section→position→entity-id index for the home screen, populated
|
/// Section→position→entity-id index for the home screen, populated
|
||||||
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
||||||
/// pins one tile slot to an entity; the actual entity data lives in
|
/// pins one tile slot to an entity; the actual entity data lives in
|
||||||
@@ -201,12 +216,13 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
|||||||
CachedHistorySnapshot,
|
CachedHistorySnapshot,
|
||||||
CachedQuarantineMine,
|
CachedQuarantineMine,
|
||||||
CachedHomeIndex,
|
CachedHomeIndex,
|
||||||
|
CachedSystemPlaylistsStatus,
|
||||||
])
|
])
|
||||||
class AppDb extends _$AppDb {
|
class AppDb extends _$AppDb {
|
||||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 6;
|
int get schemaVersion => 7;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -247,6 +263,13 @@ class AppDb extends _$AppDb {
|
|||||||
// builds still read from it as a fallback path.
|
// builds still read from it as a fallback path.
|
||||||
await m.createTable(cachedHomeIndex);
|
await m.createTable(cachedHomeIndex);
|
||||||
}
|
}
|
||||||
|
if (from < 7) {
|
||||||
|
// Schema 7: cached_system_playlists_status. Single-row
|
||||||
|
// snapshot of /api/me/system-playlists-status used by the
|
||||||
|
// home Playlists row. Empty on upgrade; first fetch
|
||||||
|
// populates.
|
||||||
|
await m.createTable(cachedSystemPlaylistsStatus);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../api/endpoints/library_lists.dart';
|
|||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../api/endpoints/me.dart';
|
import '../api/endpoints/me.dart';
|
||||||
import '../auth/auth_provider.dart' show authControllerProvider;
|
import '../auth/auth_provider.dart' show authControllerProvider;
|
||||||
|
import '../cache/adapters.dart';
|
||||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
import '../cache/cache_first.dart';
|
import '../cache/cache_first.dart';
|
||||||
import '../cache/connectivity_provider.dart';
|
import '../cache/connectivity_provider.dart';
|
||||||
@@ -19,9 +20,6 @@ import '../library/library_providers.dart' show dioProvider;
|
|||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
import '../models/history_event.dart';
|
import '../models/history_event.dart';
|
||||||
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
|
||||||
// route descriptor) that conflicts with our wire-format Page<T>.
|
|
||||||
import '../models/page.dart' as wire;
|
|
||||||
import '../models/quarantine_mine.dart';
|
import '../models/quarantine_mine.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
@@ -51,89 +49,80 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
return MeApi(await ref.watch(dioProvider.future));
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
/// Drift-first all-artists list. Reads cached_artists ordered by
|
||||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
/// sortName; GridView.builder takes care of lazy widget construction
|
||||||
/// pages append to the existing items rather than replacing them.
|
/// so loading the full set up front is fine for typical library sizes.
|
||||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
/// SWR refresh on every subscription hits /api/artists with a generous
|
||||||
static const _pageSize = 50;
|
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
|
||||||
bool _loadingMore = false;
|
///
|
||||||
|
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
|
||||||
@override
|
/// previous "fetch one page at a time as you scroll" felt like the
|
||||||
Future<wire.Paged<ArtistRef>> build() async {
|
/// rest of the app was buffering when this tab was the slow surface,
|
||||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
/// and SyncController already populates the full set of artist rows
|
||||||
return api.listArtists(limit: _pageSize, offset: 0);
|
/// via /api/library/sync.
|
||||||
}
|
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
Future<void> loadMore() async {
|
final query = db.select(db.cachedArtists)
|
||||||
if (_loadingMore) return;
|
..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]);
|
||||||
final cur = state.value;
|
return cacheFirst<CachedArtist, List<ArtistRef>>(
|
||||||
if (cur == null) return;
|
driftStream: query.watch(),
|
||||||
if (cur.items.length >= cur.total) return;
|
fetchAndPopulate: () async {
|
||||||
_loadingMore = true;
|
// Cold-cache fallback: sync should have populated drift already,
|
||||||
try {
|
// but a fresh install + first Library visit before sync completes
|
||||||
|
// will be empty. Fetch a generous chunk via /api/artists and
|
||||||
|
// persist; subsequent SWR refreshes keep things current.
|
||||||
final api = await ref.read(_libraryListsApiProvider.future);
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
final next = await api.listArtists(
|
final fresh = await api.listArtists(limit: 1000, offset: 0);
|
||||||
limit: _pageSize,
|
await db.batch((b) {
|
||||||
offset: cur.items.length,
|
b.insertAllOnConflictUpdate(
|
||||||
);
|
db.cachedArtists,
|
||||||
state = AsyncData(wire.Paged(
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
items: [...cur.items, ...next.items],
|
);
|
||||||
total: next.total,
|
});
|
||||||
limit: next.limit,
|
},
|
||||||
offset: 0,
|
toResult: (rows) => rows.map((r) => r.toRef()).toList(),
|
||||||
));
|
isOnline: () async => (await ref
|
||||||
} catch (_) {
|
.read(connectivityProvider.future)
|
||||||
// Swallow — the next near-bottom event will retry. The current
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
// partial list stays visible.
|
alwaysRefresh: true,
|
||||||
} finally {
|
tag: 'libraryArtists',
|
||||||
_loadingMore = false;
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _libraryArtistsProvider = AsyncNotifierProvider<
|
/// Drift-first all-albums list. Joins cached_albums with cached_artists
|
||||||
_LibraryArtistsNotifier,
|
/// for the artistName field. Same cold-cache fallback + SWR refresh
|
||||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
/// pattern as libraryArtistsProvider.
|
||||||
|
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
|
||||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
final db = ref.watch(appDbProvider);
|
||||||
static const _pageSize = 50;
|
final query = db.select(db.cachedAlbums).join([
|
||||||
bool _loadingMore = false;
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
@override
|
])
|
||||||
Future<wire.Paged<AlbumRef>> build() async {
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
driftStream: query.watch(),
|
||||||
}
|
fetchAndPopulate: () async {
|
||||||
|
|
||||||
Future<void> loadMore() async {
|
|
||||||
if (_loadingMore) return;
|
|
||||||
final cur = state.value;
|
|
||||||
if (cur == null) return;
|
|
||||||
if (cur.items.length >= cur.total) return;
|
|
||||||
_loadingMore = true;
|
|
||||||
try {
|
|
||||||
final api = await ref.read(_libraryListsApiProvider.future);
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
final next = await api.listAlbums(
|
final fresh = await api.listAlbums(limit: 1000, offset: 0);
|
||||||
limit: _pageSize,
|
await db.batch((b) {
|
||||||
offset: cur.items.length,
|
b.insertAllOnConflictUpdate(
|
||||||
);
|
db.cachedAlbums,
|
||||||
state = AsyncData(wire.Paged(
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
items: [...cur.items, ...next.items],
|
);
|
||||||
total: next.total,
|
});
|
||||||
limit: next.limit,
|
},
|
||||||
offset: 0,
|
toResult: (rows) => rows.map((r) {
|
||||||
));
|
final album = r.readTable(db.cachedAlbums);
|
||||||
} catch (_) {
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
// Swallow — next scroll event will retry.
|
return album.toRef(artistName: artist?.name ?? '');
|
||||||
} finally {
|
}).toList(),
|
||||||
_loadingMore = false;
|
isOnline: () async => (await ref
|
||||||
}
|
.read(connectivityProvider.future)
|
||||||
}
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
}
|
alwaysRefresh: true,
|
||||||
|
tag: 'libraryAlbums',
|
||||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
);
|
||||||
_LibraryAlbumsNotifier,
|
});
|
||||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
|
||||||
|
|
||||||
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
||||||
// last /api/me/history page as JSON in a single-row drift table, yield
|
// last /api/me/history page as JSON in a single-row drift table, yield
|
||||||
@@ -337,6 +326,19 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// Pre-warm every tab's provider on Library mount so swiping
|
||||||
|
// between tabs feels instant rather than each tab paying its
|
||||||
|
// own cold-cache cost on first visit. ref.listen subscribes
|
||||||
|
// without rebuilding LibraryScreen on emit; subscriptions stay
|
||||||
|
// alive for the lifetime of this widget. cacheFirst handles
|
||||||
|
// dedupe of concurrent fetchAndPopulate triggers.
|
||||||
|
ref.listen(_libraryArtistsProvider, (_, __) {});
|
||||||
|
ref.listen(_libraryAlbumsProvider, (_, __) {});
|
||||||
|
ref.listen(_historyProvider, (_, __) {});
|
||||||
|
ref.listen(_likedTrackIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedAlbumIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedArtistIdsProvider, (_, __) {});
|
||||||
|
ref.listen(myQuarantineProvider, (_, __) {});
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -383,24 +385,24 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryArtistsProvider).when(
|
return ref.watch(_libraryArtistsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) {
|
data: (artists) {
|
||||||
// Warm details for the first screenful so taps are instant.
|
// Warm details for the first screenful so taps are instant.
|
||||||
ref
|
ref
|
||||||
.read(metadataPrefetcherProvider)
|
.read(metadataPrefetcherProvider)
|
||||||
.warmArtists(page.items.map((a) => a.id));
|
.warmArtists(artists.map((a) => a.id));
|
||||||
return page.items.isEmpty
|
return artists.isEmpty
|
||||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||||
ref.invalidate(_libraryArtistsProvider);
|
|
||||||
await ref.read(_libraryArtistsProvider.future);
|
|
||||||
},
|
|
||||||
// Listen for scroll-near-bottom and ask the notifier
|
|
||||||
// to fetch the next page. 800px lookahead so the next
|
|
||||||
// page lands before the user reaches the visible end.
|
|
||||||
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
||||||
// the AlbumsTab pattern so the circular avatar stays
|
// the AlbumsTab pattern so the circular avatar stays
|
||||||
// a true circle on narrow grid cells.
|
// a true circle on narrow grid cells.
|
||||||
|
//
|
||||||
|
// Drift-first: GridView holds the full sorted list;
|
||||||
|
// .builder lazily realizes only visible cells, so
|
||||||
|
// even on large libraries the up-front cost is just
|
||||||
|
// a sort over cached_artists, not N network round
|
||||||
|
// trips.
|
||||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
const cols = 3;
|
const cols = 3;
|
||||||
const sidePad = 8.0;
|
const sidePad = 8.0;
|
||||||
@@ -412,36 +414,25 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
|
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
|
||||||
// + slack matched to AlbumsTab's overflow guard.
|
// + slack matched to AlbumsTab's overflow guard.
|
||||||
final cellH = (cellW - 16) + 8 + 18 + 8;
|
final cellH = (cellW - 16) + 8 + 18 + 8;
|
||||||
return NotificationListener<ScrollNotification>(
|
return GridView.builder(
|
||||||
onNotification: (n) {
|
padding: const EdgeInsets.all(sidePad),
|
||||||
if (n.metrics.pixels >=
|
gridDelegate:
|
||||||
n.metrics.maxScrollExtent - 800) {
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
ref
|
crossAxisCount: cols,
|
||||||
.read(_libraryArtistsProvider.notifier)
|
mainAxisExtent: cellH,
|
||||||
.loadMore();
|
mainAxisSpacing: gap,
|
||||||
}
|
crossAxisSpacing: gap,
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: GridView.builder(
|
|
||||||
padding: const EdgeInsets.all(sidePad),
|
|
||||||
gridDelegate:
|
|
||||||
SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: cols,
|
|
||||||
mainAxisExtent: cellH,
|
|
||||||
mainAxisSpacing: gap,
|
|
||||||
crossAxisSpacing: gap,
|
|
||||||
),
|
|
||||||
itemCount: page.items.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final artist = page.items[i];
|
|
||||||
return ArtistCard(
|
|
||||||
artist: artist,
|
|
||||||
width: cellW,
|
|
||||||
onTap: () => ctx.push('/artists/${artist.id}',
|
|
||||||
extra: artist),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
itemCount: artists.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final artist = artists[i];
|
||||||
|
return ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
width: cellW,
|
||||||
|
onTap: () => ctx.push('/artists/${artist.id}',
|
||||||
|
extra: artist),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -459,17 +450,16 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryAlbumsProvider).when(
|
return ref.watch(_libraryAlbumsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) {
|
data: (albums) {
|
||||||
return page.items.isEmpty
|
return albums.isEmpty
|
||||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||||
ref.invalidate(_libraryAlbumsProvider);
|
|
||||||
await ref.read(_libraryAlbumsProvider.future);
|
|
||||||
},
|
|
||||||
// Same responsive 3-up grid as the artist detail
|
// Same responsive 3-up grid as the artist detail
|
||||||
// album list — LayoutBuilder + AlbumCard sized to the
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
// cell, mainAxisExtent matched to actual card height.
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
|
// Drift-first; full sorted list, lazy realization via
|
||||||
|
// GridView.builder.
|
||||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
const cols = 3;
|
const cols = 3;
|
||||||
const sidePad = 8.0;
|
const sidePad = 8.0;
|
||||||
@@ -484,37 +474,26 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
// would otherwise overflow the cell by a pixel
|
// would otherwise overflow the cell by a pixel
|
||||||
// (logged as a noisy RenderFlex warning).
|
// (logged as a noisy RenderFlex warning).
|
||||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||||
return NotificationListener<ScrollNotification>(
|
return GridView.builder(
|
||||||
onNotification: (n) {
|
padding: const EdgeInsets.all(sidePad),
|
||||||
if (n.metrics.pixels >=
|
gridDelegate:
|
||||||
n.metrics.maxScrollExtent - 800) {
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
ref
|
crossAxisCount: cols,
|
||||||
.read(_libraryAlbumsProvider.notifier)
|
mainAxisExtent: cellH,
|
||||||
.loadMore();
|
mainAxisSpacing: gap,
|
||||||
}
|
crossAxisSpacing: gap,
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: GridView.builder(
|
|
||||||
padding: const EdgeInsets.all(sidePad),
|
|
||||||
gridDelegate:
|
|
||||||
SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: cols,
|
|
||||||
mainAxisExtent: cellH,
|
|
||||||
mainAxisSpacing: gap,
|
|
||||||
crossAxisSpacing: gap,
|
|
||||||
),
|
|
||||||
itemCount: page.items.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final album = page.items[i];
|
|
||||||
return AlbumCard(
|
|
||||||
album: album,
|
|
||||||
width: cellW,
|
|
||||||
titleMaxLines: 2,
|
|
||||||
onTap: () => ctx.push('/albums/${album.id}',
|
|
||||||
extra: album),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final album = albums[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
onTap: () => ctx.push('/albums/${album.id}',
|
||||||
|
extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
@@ -362,8 +363,41 @@ class _TrackRefRow {
|
|||||||
final int durationSec;
|
final int durationSec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drift-first per #357 pattern. Reads from cached_system_playlists_
|
||||||
|
/// status (single-row JSON blob populated by /api/me/system-playlists-
|
||||||
|
/// status). Home Playlists row reads this every render to choose
|
||||||
|
/// between real cards and "building / pending / failed" placeholders
|
||||||
|
/// — drift-first means the row paints with the prior status instantly
|
||||||
|
/// rather than flickering through SystemPlaylistsStatus.empty() while
|
||||||
|
/// the REST round-trip resolves. SWR refresh on every visit keeps it
|
||||||
|
/// current.
|
||||||
final systemPlaylistsStatusProvider =
|
final systemPlaylistsStatusProvider =
|
||||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
StreamProvider<SystemPlaylistsStatus>((ref) {
|
||||||
final dio = await ref.watch(dioProvider.future);
|
final db = ref.watch(appDbProvider);
|
||||||
return MeApi(dio).systemPlaylistsStatus();
|
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
|
||||||
|
driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final fresh = await MeApi(dio).systemPlaylistsStatus();
|
||||||
|
await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate(
|
||||||
|
CachedSystemPlaylistsStatusCompanion.insert(
|
||||||
|
json: jsonEncode({
|
||||||
|
'in_flight': fresh.inFlight,
|
||||||
|
'last_run_at': fresh.lastRunAt,
|
||||||
|
'last_error': fresh.lastError,
|
||||||
|
}),
|
||||||
|
updatedAt: drift.Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.isEmpty
|
||||||
|
? SystemPlaylistsStatus.empty()
|
||||||
|
: SystemPlaylistsStatus.fromJson(
|
||||||
|
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'systemPlaylistsStatus',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ void main() {
|
|||||||
(ref) => Stream.value(PlaylistsList.empty()),
|
(ref) => Stream.value(PlaylistsList.empty()),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
@@ -49,7 +49,7 @@ void main() {
|
|||||||
(ref) => Stream.value(PlaylistsList.empty()),
|
(ref) => Stream.value(PlaylistsList.empty()),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
@@ -91,7 +91,7 @@ void main() {
|
|||||||
const PlaylistsList(owned: [forYou], public: [])),
|
const PlaylistsList(owned: [forYou], public: [])),
|
||||||
),
|
),
|
||||||
systemPlaylistsStatusProvider.overrideWith(
|
systemPlaylistsStatusProvider.overrideWith(
|
||||||
(ref) async => SystemPlaylistsStatus.empty(),
|
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||||
|
|||||||
Reference in New Issue
Block a user