feat(flutter): drift-first Liked tabs

Slice 3 of the smooth-loading pass. The three _likedTracksProvider /
_likedAlbumsProvider / _likedArtistsProvider entries on the Library
screen migrate from FutureProvider+REST to StreamProvider+cacheFirst.

Reads now flow from cached_likes joined against the metadata tables
SyncController already keeps fresh; LikesController's optimistic drift
write makes toggling a like re-emit these streams instantly without a
REST round-trip. Cold-cache fallback hits /api/likes/* when drift is
empty (fresh install pre-first-sync). SWR refresh on each visit catches
likes from other devices that haven't propagated via library_changes
yet.

The original FutureProvider versions fetched the first 50 rows. Drift
returns everything cached_likes knows about — for typical libraries
that's the full liked list. Pagination can come back when liked lists
are big enough to matter.

Like/unlike SSE invalidation paths preserved so cross-device updates
still feel real-time, even when the sender's library_changes hasn't
landed here yet.
This commit is contained in:
2026-05-13 18:18:14 -04:00
parent 32c8d4f28f
commit 1a2de0e738
+186 -6
View File
@@ -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<HistoryPage>((ref) async {
return (await ref.watch(_meApiProvider.future)).history();
});
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((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<wire.Paged<TrackRef>>((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,
);
},
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedTracks',
);
});
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listAlbums();
final _likedAlbumsProvider = StreamProvider<wire.Paged<AlbumRef>>((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,
);
},
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedAlbums',
);
});
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listArtists();
final _likedArtistsProvider = StreamProvider<wire.Paged<ArtistRef>>((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,
);
},
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedArtists',
);
});
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with