diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 159160f0..75391edf 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -5,6 +6,12 @@ import 'package:go_router/go_router.dart'; 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'; +import '../cache/db.dart'; import '../cache/metadata_prefetcher.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/album.dart'; @@ -129,16 +136,189 @@ final _historyProvider = FutureProvider((ref) async { return (await ref.watch(_meApiProvider.future)).history(); }); -final _likedTracksProvider = FutureProvider>((ref) async { - return (await ref.watch(_likesApiProvider.future)).listTracks(); +// Drift-first Liked tabs (#357 plan C). Read from cached_likes joined +// against cached_tracks / cached_albums / cached_artists. SyncController +// keeps both sides fresh; LikesController mutates cached_likes +// optimistically, so toggling a like re-emits these streams instantly +// for snappy UI. REST cold-cache fallback hits /api/likes/* when drift +// is empty (fresh install, first sync still pending). SWR refresh on +// every visit catches server-side likes the library_changes log hasn't +// shipped yet (e.g. a like from another device that fired just before +// this screen mounted). +// +// The original FutureProvider versions fetched the first 50 rows; drift +// returns everything cached_likes knows about. Pagination can come back +// when liked lists are big enough to matter — typical libraries are +// well under the 50-row ceiling. + +final _likedTracksProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedLikes).join([ + drift.innerJoin(db.cachedTracks, + db.cachedTracks.id.equalsExp(db.cachedLikes.entityId)), + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), + drift.leftOuterJoin(db.cachedAlbums, + db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), + ]) + ..where(db.cachedLikes.entityType.equals('track')) + ..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]); + + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { + final api = await ref.read(_likesApiProvider.future); + final user = ref.read(authControllerProvider).value; + if (user == null) return; + final fresh = await api.listTracks(); + await db.batch((b) { + // Track metadata via toDrift() — artistName/albumTitle come + // from the join, so we don't need to denormalize them here. + b.insertAllOnConflictUpdate( + db.cachedTracks, + fresh.items.map((t) => t.toDrift()).toList(), + ); + // Like rows via insertOrIgnore so existing rows keep their + // original likedAt (preserves the ORDER BY likedAt DESC). + for (final t in fresh.items) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'track', + entityId: t.id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + }); + }, + toResult: (rows) { + final tracks = rows.map((r) { + final t = r.readTable(db.cachedTracks); + final a = r.readTableOrNull(db.cachedArtists); + final al = r.readTableOrNull(db.cachedAlbums); + return t.toRef( + artistName: a?.name ?? '', + albumTitle: al?.title ?? '', + ); + }).toList(); + return wire.Paged( + items: tracks, + total: tracks.length, + limit: tracks.length, + offset: 0, + ); + }, + isOnline: () async => (await ref.read(connectivityProvider.future)), + alwaysRefresh: true, + tag: 'likedTracks', + ); }); -final _likedAlbumsProvider = FutureProvider>((ref) async { - return (await ref.watch(_likesApiProvider.future)).listAlbums(); +final _likedAlbumsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedLikes).join([ + drift.innerJoin(db.cachedAlbums, + db.cachedAlbums.id.equalsExp(db.cachedLikes.entityId)), + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), + ]) + ..where(db.cachedLikes.entityType.equals('album')) + ..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]); + + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { + final api = await ref.read(_likesApiProvider.future); + final user = ref.read(authControllerProvider).value; + if (user == null) return; + final fresh = await api.listAlbums(); + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedAlbums, + fresh.items.map((a) => a.toDrift()).toList(), + ); + for (final a in fresh.items) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'album', + entityId: a.id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + }); + }, + toResult: (rows) { + final albums = rows.map((r) { + final al = r.readTable(db.cachedAlbums); + final a = r.readTableOrNull(db.cachedArtists); + return al.toRef(artistName: a?.name ?? ''); + }).toList(); + return wire.Paged( + items: albums, + total: albums.length, + limit: albums.length, + offset: 0, + ); + }, + isOnline: () async => (await ref.read(connectivityProvider.future)), + alwaysRefresh: true, + tag: 'likedAlbums', + ); }); -final _likedArtistsProvider = FutureProvider>((ref) async { - return (await ref.watch(_likesApiProvider.future)).listArtists(); +final _likedArtistsProvider = StreamProvider>((ref) { + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedLikes).join([ + drift.innerJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedLikes.entityId)), + ]) + ..where(db.cachedLikes.entityType.equals('artist')) + ..orderBy([drift.OrderingTerm.desc(db.cachedLikes.likedAt)]); + + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { + final api = await ref.read(_likesApiProvider.future); + final user = ref.read(authControllerProvider).value; + if (user == null) return; + final fresh = await api.listArtists(); + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedArtists, + fresh.items.map((a) => a.toDrift()).toList(), + ); + for (final a in fresh.items) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'artist', + entityId: a.id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + }); + }, + toResult: (rows) { + final artists = + rows.map((r) => r.readTable(db.cachedArtists).toRef()).toList(); + return wire.Paged( + items: artists, + total: artists.length, + limit: artists.length, + offset: 0, + ); + }, + isOnline: () async => (await ref.read(connectivityProvider.future)), + alwaysRefresh: true, + tag: 'likedArtists', + ); }); // Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with