feat(flutter): liked tabs on per-item rendering (Slice E)

The three liked-tab providers now yield ordered lists of entity IDs
(read from cached_likes ORDER BY likedAt DESC). The UI renders
per-tile widgets that hydrate each entity individually via
albumTileProvider / artistTileProvider / trackTileProvider.

fetchAndPopulate dropped from the per-provider bulk endpoints to a
single shared call against /api/likes/ids — much cheaper, and the
tile providers handle entity hydration themselves. The bulk
/api/likes/{tracks,albums,artists} endpoints are no longer in the
Flutter cold path (server keeps serving them for web compat).

Cross-device SSE invalidate paths preserved so cross-device likes
still feel instant. Local LikesController mutations propagate via
cached_likes optimistic writes — same drift watch() route as before.

Tap-to-play on a track row uses currently-hydrated TrackRefs as the
play queue; still-loading tracks are skipped and join on next
rebuild as hydrations land.
This commit is contained in:
2026-05-13 21:55:20 -04:00
parent a324454efe
commit f2fa441405
+197 -215
View File
@@ -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<wire.Paged<TrackRef>>((ref) {
List<String> _idsForEntity(List<CachedLike> rows) =>
rows.map((r) => r.entityId).toList(growable: false);
final _likedTrackIdsProvider = StreamProvider<List<String>>((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<drift.TypedResult, wire.Paged<TrackRef>>(
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<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedTracks',
tag: 'likedTrackIds',
);
});
final _likedAlbumsProvider = StreamProvider<wire.Paged<AlbumRef>>((ref) {
final _likedAlbumIdsProvider = StreamProvider<List<String>>((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<drift.TypedResult, wire.Paged<AlbumRef>>(
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<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedAlbums',
tag: 'likedAlbumIds',
);
});
final _likedArtistsProvider = StreamProvider<wire.Paged<ArtistRef>>((ref) {
final _likedArtistIdsProvider = StreamProvider<List<String>>((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<drift.TypedResult, wire.Paged<ArtistRef>>(
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<CachedLike, List<String>>(
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<void> _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<FabledSwordTheme>()!;
// #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<AsyncValue<LiveEvent>>(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<String> 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 = <TrackRef>[];
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();