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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user