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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/library_lists.dart';
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../api/endpoints/me.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 '../cache/metadata_prefetcher.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
@@ -129,16 +136,189 @@ final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
|||||||
return (await ref.watch(_meApiProvider.future)).history();
|
return (await ref.watch(_meApiProvider.future)).history();
|
||||||
});
|
});
|
||||||
|
|
||||||
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
// Drift-first Liked tabs (#357 plan C). Read from cached_likes joined
|
||||||
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
// 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 {
|
final _likedAlbumsProvider = StreamProvider<wire.Paged<AlbumRef>>((ref) {
|
||||||
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
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 {
|
final _likedArtistsProvider = StreamProvider<wire.Paged<ArtistRef>>((ref) {
|
||||||
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
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
|
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
||||||
|
|||||||
Reference in New Issue
Block a user