diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 23b1c1ba..ca4e90ab 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -15,6 +15,7 @@ import '../cache/cache_first.dart'; import '../cache/connectivity_provider.dart'; import '../cache/db.dart'; import '../cache/metadata_prefetcher.dart'; +import '../cache/tile_providers.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/album.dart'; import '../models/artist.dart'; @@ -28,6 +29,7 @@ import '../player/player_provider.dart'; import '../quarantine/quarantine_provider.dart'; import '../shared/live_events_provider.dart'; import '../shared/widgets/main_app_bar_actions.dart'; +import '../shared/widgets/skeletons.dart'; import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; import 'widgets/album_card.dart'; @@ -196,191 +198,122 @@ String _encodeHistoryPage(HistoryPage h) => jsonEncode({ 'has_more': h.hasMore, }); -// 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). +// Per-item Liked tabs (Slice E of the per-item rendering pass). +// Each provider yields just the ordered list of entity IDs; the UI +// then renders per-tile widgets that hydrate each entity individually +// via albumTileProvider / artistTileProvider / trackTileProvider. // -// 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. +// Reads come from cached_likes (sync- and optimistic-write-populated), +// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the +// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints +// that returned fully denormalized entities are no longer needed for +// this path since tile providers handle entity hydration themselves. +// +// likedAt ordering note: cached_likes.likedAt is whatever drift +// assigned via currentDateAndTime when the row was first inserted +// (either by sync or LikesController). insertOrIgnore on subsequent +// fetches preserves the existing likedAt so ordering stays stable. +// Approximate but acceptable — matches prior Slice 3 behavior. -final _likedTracksProvider = StreamProvider>((ref) { +List _idsForEntity(List rows) => + rows.map((r) => r.entityId).toList(growable: false); + +final _likedTrackIdsProvider = 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, - ); - }, + final query = (db.select(db.cachedLikes) + ..where((t) => t.entityType.equals('track')) + ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) + .watch(); + return cacheFirst>( + driftStream: query, + fetchAndPopulate: () => _populateLikeIds(ref), + toResult: _idsForEntity, isOnline: () async => (await ref.read(connectivityProvider.future)), alwaysRefresh: true, - tag: 'likedTracks', + tag: 'likedTrackIds', ); }); -final _likedAlbumsProvider = StreamProvider>((ref) { +final _likedAlbumIdsProvider = 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, - ); - }, + final query = (db.select(db.cachedLikes) + ..where((t) => t.entityType.equals('album')) + ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) + .watch(); + return cacheFirst>( + driftStream: query, + fetchAndPopulate: () => _populateLikeIds(ref), + toResult: _idsForEntity, isOnline: () async => (await ref.read(connectivityProvider.future)), alwaysRefresh: true, - tag: 'likedAlbums', + tag: 'likedAlbumIds', ); }); -final _likedArtistsProvider = StreamProvider>((ref) { +final _likedArtistIdsProvider = 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, - ); - }, + final query = (db.select(db.cachedLikes) + ..where((t) => t.entityType.equals('artist')) + ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) + .watch(); + return cacheFirst>( + driftStream: query, + fetchAndPopulate: () => _populateLikeIds(ref), + toResult: _idsForEntity, isOnline: () async => (await ref.read(connectivityProvider.future)), alwaysRefresh: true, - tag: 'likedArtists', + tag: 'likedArtistIds', ); }); +/// Shared cold-cache populator: hits /api/likes/ids once and writes +/// rows for all three entity types via insertOrIgnore. The three +/// providers above all trigger this on their first empty-drift +/// emission; the dedup in cacheFirst's revalidate state plus drift's +/// insertOrIgnore semantics make the multiple-trigger case cheap. +Future _populateLikeIds(Ref ref) async { + final api = await ref.read(_likesApiProvider.future); + final user = ref.read(authControllerProvider).value; + if (user == null) return; + final fresh = await api.ids(); + final db = ref.read(appDbProvider); + await db.batch((b) { + for (final id in fresh.tracks) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'track', + entityId: id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + for (final id in fresh.albums) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'album', + entityId: id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + for (final id in fresh.artists) { + b.insert( + db.cachedLikes, + CachedLikesCompanion.insert( + userId: user.id, + entityType: 'artist', + entityId: id, + ), + mode: drift.InsertMode.insertOrIgnore, + ); + } + }); +} + // Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with // optimistic flag/unflag) so flagging from any kebab and unflagging from // the Hidden tab keep one source of truth. @@ -616,10 +549,11 @@ class _LikedTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - // #402 wire-up: when any like/unlike event arrives via SSE, - // invalidate all three Liked sub-lists. Server-side events are - // already user-scoped (publishLikeEvent attaches user_id), so the - // dispatcher filters out other users' events upstream. + // SSE wire-up: any cross-device like / unlike triggers a refresh + // of the discovery providers. LikesController handles local + // mutations optimistically through the same cached_likes table, + // so toggling a like locally re-emits the streams instantly + // without needing an invalidate here. ref.listen>(liveEventsProvider, (_, next) { final e = next.asData?.value; if (e == null) return; @@ -630,84 +564,67 @@ class _LikedTab extends ConsumerWidget { case 'album.unliked': case 'artist.liked': case 'artist.unliked': - ref.invalidate(_likedTracksProvider); - ref.invalidate(_likedAlbumsProvider); - ref.invalidate(_likedArtistsProvider); + ref.invalidate(_likedTrackIdsProvider); + ref.invalidate(_likedAlbumIdsProvider); + ref.invalidate(_likedArtistIdsProvider); } }); - final tracksA = ref.watch(_likedTracksProvider); - final albumsA = ref.watch(_likedAlbumsProvider); - final artistsA = ref.watch(_likedArtistsProvider); + final tracksA = ref.watch(_likedTrackIdsProvider); + final albumsA = ref.watch(_likedAlbumIdsProvider); + final artistsA = ref.watch(_likedArtistIdsProvider); if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { return const Center(child: CircularProgressIndicator()); } - final t = tracksA.value; - final al = albumsA.value; - final ar = artistsA.value; - if (t == null || al == null || ar == null) { + final trackIds = tracksA.value; + final albumIds = albumsA.value; + final artistIds = artistsA.value; + if (trackIds == null || albumIds == null || artistIds == null) { return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); } - if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) { + if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) { return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center)); } return RefreshIndicator( onRefresh: () async { await Future.wait([ - ref.refresh(_likedTracksProvider.future), - ref.refresh(_likedAlbumsProvider.future), - ref.refresh(_likedArtistsProvider.future), + ref.refresh(_likedTrackIdsProvider.future), + ref.refresh(_likedAlbumIdsProvider.future), + ref.refresh(_likedArtistIdsProvider.future), ]); }, child: ListView(children: [ - if (ar.items.isNotEmpty) ...[ - _SectionHeader(label: 'Artists', count: ar.total), + if (artistIds.isNotEmpty) ...[ + _SectionHeader(label: 'Artists', count: artistIds.length), SizedBox( height: 168, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: ar.items.length, - itemBuilder: (ctx, i) { - final artist = ar.items[i]; - return ArtistCard( - artist: artist, - onTap: () => - ctx.push('/artists/${artist.id}', extra: artist), - ); - }, + itemCount: artistIds.length, + itemBuilder: (ctx, i) => + _LikedArtistTile(id: artistIds[i]), ), ), ], - if (al.items.isNotEmpty) ...[ - _SectionHeader(label: 'Albums', count: al.total), + if (albumIds.isNotEmpty) ...[ + _SectionHeader(label: 'Albums', count: albumIds.length), SizedBox( height: 200, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: al.items.length, - itemBuilder: (ctx, i) { - final album = al.items[i]; - return AlbumCard( - album: album, - onTap: () => - ctx.push('/albums/${album.id}', extra: album), - ); - }, + itemCount: albumIds.length, + itemBuilder: (ctx, i) => + _LikedAlbumTile(id: albumIds[i]), ), ), ], - if (t.items.isNotEmpty) ...[ - _SectionHeader(label: 'Tracks', count: t.total), - ...t.items.asMap().entries.map((e) { - return TrackRow( - track: e.value, - onTap: () => ref - .read(playerActionsProvider) - .playTracks(t.items, initialIndex: e.key), - ); - }), + if (trackIds.isNotEmpty) ...[ + _SectionHeader(label: 'Tracks', count: trackIds.length), + ...trackIds.map( + (id) => _LikedTrackRow(id: id, sectionIds: trackIds), + ), ], const SizedBox(height: 96), ]), @@ -715,6 +632,71 @@ class _LikedTab extends ConsumerWidget { } } +/// Liked-Artists carousel tile. Skeleton until artistTileProvider +/// yields a populated row. +class _LikedArtistTile extends ConsumerWidget { + const _LikedArtistTile({required this.id}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final artist = ref.watch(artistTileProvider(id)).asData?.value; + if (artist == null) return const SkeletonArtistTile(); + return ArtistCard( + artist: artist, + onTap: () => context.push('/artists/${artist.id}', extra: artist), + ); + } +} + +/// Liked-Albums carousel tile. Skeleton until albumTileProvider +/// yields a populated row. +class _LikedAlbumTile extends ConsumerWidget { + const _LikedAlbumTile({required this.id}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(albumTileProvider(id)).asData?.value; + if (album == null) return const SkeletonAlbumTile(); + return AlbumCard( + album: album, + onTap: () => context.push('/albums/${album.id}', extra: album), + ); + } +} + +/// Liked-Tracks list row. Skeleton until trackTileProvider yields a +/// populated row. Tap plays the section starting at this track, +/// using whichever tracks are currently hydrated; still-loading +/// tracks are skipped from the play queue and join on next rebuild. +class _LikedTrackRow extends ConsumerWidget { + const _LikedTrackRow({required this.id, required this.sectionIds}); + final String id; + final List sectionIds; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final track = ref.watch(trackTileProvider(id)).asData?.value; + if (track == null) return const SkeletonTrackRow(); + return TrackRow( + track: track, + onTap: () { + final hydrated = []; + for (final i in sectionIds) { + final v = ref.read(trackTileProvider(i)).asData?.value; + if (v != null) hydrated.add(v); + } + final start = hydrated.indexWhere((t) => t.id == id); + ref.read(playerActionsProvider).playTracks( + hydrated, + initialIndex: start < 0 ? 0 : start, + ); + }, + ); + } +} + class _HiddenTab extends ConsumerWidget { const _HiddenTab();