diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index d2227677..b3fca3b1 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -142,6 +142,21 @@ class CachedHistorySnapshot extends Table { Set 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 get primaryKey => {id}; +} + /// Section→position→entity-id index for the home screen, populated /// from the per-item discovery endpoint `/api/home/index`. Each row /// 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, CachedQuarantineMine, CachedHomeIndex, + CachedSystemPlaylistsStatus, ]) class AppDb extends _$AppDb { AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -247,6 +263,13 @@ class AppDb extends _$AppDb { // builds still read from it as a fallback path. 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); + } }, ); } diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index c300294a..8392c728 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -9,6 +9,7 @@ import '../api/endpoints/library_lists.dart'; import '../api/endpoints/likes.dart'; import '../api/endpoints/me.dart'; import '../auth/auth_provider.dart' show authControllerProvider; +import '../cache/adapters.dart'; import '../cache/audio_cache_manager.dart' show appDbProvider; import '../cache/cache_first.dart'; import '../cache/connectivity_provider.dart'; @@ -19,9 +20,6 @@ import '../library/library_providers.dart' show dioProvider; import '../models/album.dart'; import '../models/artist.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. -import '../models/page.dart' as wire; import '../models/quarantine_mine.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; @@ -51,89 +49,80 @@ final _meApiProvider = FutureProvider((ref) async { return MeApi(await ref.watch(dioProvider.future)); }); -/// Paginated artist list. AsyncNotifier so the screen can call -/// `loadMore()` when the scroll approaches the bottom; subsequent -/// pages append to the existing items rather than replacing them. -class _LibraryArtistsNotifier extends AsyncNotifier> { - static const _pageSize = 50; - bool _loadingMore = false; - - @override - Future> build() async { - final api = await ref.watch(_libraryListsApiProvider.future); - return api.listArtists(limit: _pageSize, offset: 0); - } - - Future loadMore() async { - if (_loadingMore) return; - final cur = state.value; - if (cur == null) return; - if (cur.items.length >= cur.total) return; - _loadingMore = true; - try { +/// Drift-first all-artists list. Reads cached_artists ordered by +/// sortName; GridView.builder takes care of lazy widget construction +/// so loading the full set up front is fine for typical library sizes. +/// SWR refresh on every subscription hits /api/artists with a generous +/// limit so newly-scanned-but-not-yet-synced rows land soon after. +/// +/// The old AsyncNotifier+loadMore infinite-scroll path went away: the +/// previous "fetch one page at a time as you scroll" felt like the +/// rest of the app was buffering when this tab was the slow surface, +/// and SyncController already populates the full set of artist rows +/// via /api/library/sync. +final _libraryArtistsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedArtists) + ..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]); + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { + // Cold-cache fallback: sync should have populated drift already, + // 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 next = await api.listArtists( - limit: _pageSize, - offset: cur.items.length, - ); - state = AsyncData(wire.Paged( - items: [...cur.items, ...next.items], - total: next.total, - limit: next.limit, - offset: 0, - )); - } catch (_) { - // Swallow — the next near-bottom event will retry. The current - // partial list stays visible. - } finally { - _loadingMore = false; - } - } -} + final fresh = await api.listArtists(limit: 1000, offset: 0); + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedArtists, + fresh.items.map((a) => a.toDrift()).toList(), + ); + }); + }, + toResult: (rows) => rows.map((r) => r.toRef()).toList(), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + alwaysRefresh: true, + tag: 'libraryArtists', + ); +}); -final _libraryArtistsProvider = AsyncNotifierProvider< - _LibraryArtistsNotifier, - wire.Paged>(_LibraryArtistsNotifier.new); - -class _LibraryAlbumsNotifier extends AsyncNotifier> { - static const _pageSize = 50; - bool _loadingMore = false; - - @override - Future> build() async { - final api = await ref.watch(_libraryListsApiProvider.future); - return api.listAlbums(limit: _pageSize, offset: 0); - } - - Future loadMore() async { - if (_loadingMore) return; - final cur = state.value; - if (cur == null) return; - if (cur.items.length >= cur.total) return; - _loadingMore = true; - try { +/// Drift-first all-albums list. Joins cached_albums with cached_artists +/// for the artistName field. Same cold-cache fallback + SWR refresh +/// pattern as libraryArtistsProvider. +final _libraryAlbumsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedAlbums).join([ + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), + ]) + ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { final api = await ref.read(_libraryListsApiProvider.future); - final next = await api.listAlbums( - limit: _pageSize, - offset: cur.items.length, - ); - state = AsyncData(wire.Paged( - items: [...cur.items, ...next.items], - total: next.total, - limit: next.limit, - offset: 0, - )); - } catch (_) { - // Swallow — next scroll event will retry. - } finally { - _loadingMore = false; - } - } -} - -final _libraryAlbumsProvider = AsyncNotifierProvider< - _LibraryAlbumsNotifier, - wire.Paged>(_LibraryAlbumsNotifier.new); + final fresh = await api.listAlbums(limit: 1000, offset: 0); + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedAlbums, + fresh.items.map((a) => a.toDrift()).toList(), + ); + }); + }, + toResult: (rows) => rows.map((r) { + final album = r.readTable(db.cachedAlbums); + final artist = r.readTableOrNull(db.cachedArtists); + return album.toRef(artistName: artist?.name ?? ''); + }).toList(), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + alwaysRefresh: true, + tag: 'libraryAlbums', + ); +}); // Drift-first History tab. Mirrors homeProvider's pattern: store the // last /api/me/history page as JSON in a single-row drift table, yield @@ -337,6 +326,19 @@ class _LibraryScreenState extends ConsumerState @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; + // 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( backgroundColor: fs.obsidian, appBar: AppBar( @@ -383,24 +385,24 @@ class _ArtistsTab extends ConsumerWidget { return ref.watch(_libraryArtistsProvider).when( loading: () => const Center(child: CircularProgressIndicator()), 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. ref .read(metadataPrefetcherProvider) - .warmArtists(page.items.map((a) => a.id)); - return page.items.isEmpty + .warmArtists(artists.map((a) => a.id)); + 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)) : RefreshIndicator( - onRefresh: () async { - 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. + onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), // LayoutBuilder + cell-aware ArtistCard width mirrors // the AlbumsTab pattern so the circular avatar stays // 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) { const cols = 3; const sidePad = 8.0; @@ -412,36 +414,25 @@ class _ArtistsTab extends ConsumerWidget { // avatar (cellW - 16) + gap (8) + 1 line name (~18) // + slack matched to AlbumsTab's overflow guard. final cellH = (cellW - 16) + 8 + 18 + 8; - return NotificationListener( - onNotification: (n) { - if (n.metrics.pixels >= - n.metrics.maxScrollExtent - 800) { - ref - .read(_libraryArtistsProvider.notifier) - .loadMore(); - } - 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), - ); - }, + return GridView.builder( + padding: const EdgeInsets.all(sidePad), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisExtent: cellH, + mainAxisSpacing: gap, + crossAxisSpacing: gap, ), + 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( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (page) { - return page.items.isEmpty + data: (albums) { + 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)) : RefreshIndicator( - onRefresh: () async { - ref.invalidate(_libraryAlbumsProvider); - await ref.read(_libraryAlbumsProvider.future); - }, + onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future), // Same responsive 3-up grid as the artist detail // album list — LayoutBuilder + AlbumCard sized to the // cell, mainAxisExtent matched to actual card height. + // Drift-first; full sorted list, lazy realization via + // GridView.builder. child: LayoutBuilder(builder: (ctx, constraints) { const cols = 3; const sidePad = 8.0; @@ -484,37 +474,26 @@ class _AlbumsTab extends ConsumerWidget { // would otherwise overflow the cell by a pixel // (logged as a noisy RenderFlex warning). final cellH = (cellW - 16) + 8 + 36 + 16 + 8; - return NotificationListener( - onNotification: (n) { - if (n.metrics.pixels >= - n.metrics.maxScrollExtent - 800) { - ref - .read(_libraryAlbumsProvider.notifier) - .loadMore(); - } - 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), - ); - }, + return GridView.builder( + padding: const EdgeInsets.all(sidePad), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisExtent: cellH, + mainAxisSpacing: gap, + crossAxisSpacing: gap, ), + 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), + ); + }, ); }), ); diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index 9b4e9c64..2c8be2df 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart' as drift; @@ -362,8 +363,41 @@ class _TrackRefRow { 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 = - FutureProvider((ref) async { - final dio = await ref.watch(dioProvider.future); - return MeApi(dio).systemPlaylistsStatus(); + StreamProvider((ref) { + final db = ref.watch(appDbProvider); + return cacheFirst( + 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), + isOnline: () async => (await ref + .read(connectivityProvider.future) + .timeout(const Duration(seconds: 3), onTimeout: () => true)), + alwaysRefresh: true, + tag: 'systemPlaylistsStatus', + ); }); diff --git a/flutter_client/test/library/home_screen_test.dart b/flutter_client/test/library/home_screen_test.dart index be869cc6..0212ad81 100644 --- a/flutter_client/test/library/home_screen_test.dart +++ b/flutter_client/test/library/home_screen_test.dart @@ -28,7 +28,7 @@ void main() { (ref) => Stream.value(PlaylistsList.empty()), ), systemPlaylistsStatusProvider.overrideWith( - (ref) async => SystemPlaylistsStatus.empty(), + (ref) => Stream.value(SystemPlaylistsStatus.empty()), ), ], child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), @@ -49,7 +49,7 @@ void main() { (ref) => Stream.value(PlaylistsList.empty()), ), systemPlaylistsStatusProvider.overrideWith( - (ref) async => SystemPlaylistsStatus.empty(), + (ref) => Stream.value(SystemPlaylistsStatus.empty()), ), ], child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), @@ -91,7 +91,7 @@ void main() { const PlaylistsList(owned: [forYou], public: [])), ), systemPlaylistsStatusProvider.overrideWith( - (ref) async => SystemPlaylistsStatus.empty(), + (ref) => Stream.value(SystemPlaylistsStatus.empty()), ), ], child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),