feat(flutter/cache): migrate playlist providers to drift-first (#357 plan C)
playlistsListProvider — StreamProvider over cached_playlists. Returns all user-owned + others' public playlists. The 'kind' family arg only affects the REST cold-cache fetch (server-side filter); drift can't distinguish 'user' from 'system' kind because systemVariant isn't persisted. v1 limitation: add-to-playlist sheet may briefly show system playlists too. Follow-up: add systemVariant column + schema bump. playlistDetailProvider — async* over the playlist watch stream + a one-shot tracks query per emission (joins playlist_tracks → tracks → artists → albums for snapshot fields). Cold-cache fallback inserts playlist + playlist_tracks rows in one batch, then awaits re-emission. Pull-to-refresh on these screens now triggers re-subscription rather than forcing a REST fetch — drift is the source of truth and stays fresh via SyncController's delta sync. Documented behaviour shift; if operator wants force-refetch we can add it via a sync trigger later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../api/endpoints/playlists.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
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 '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
@@ -10,14 +17,139 @@ final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
||||
return PlaylistsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Drift-first per #357 plan C. Returns all cached playlists for the
|
||||
/// current user (owned) + public playlists from others. The `kind`
|
||||
/// family arg only affects the REST cold-cache fetch shape — drift
|
||||
/// can't distinguish 'user' from 'system' kind because systemVariant
|
||||
/// isn't persisted (TODO: add column + schema bump in a follow-up).
|
||||
/// For v1 this means add-to-playlist sheet shows system playlists too.
|
||||
final playlistsListProvider =
|
||||
FutureProvider.family<PlaylistsList, String>((ref, kind) async {
|
||||
return (await ref.watch(playlistsApiProvider.future)).list(kind: kind);
|
||||
StreamProvider.family<PlaylistsList, String>((ref, kind) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
|
||||
return cacheFirst<CachedPlaylist, PlaylistsList>(
|
||||
driftStream: db.select(db.cachedPlaylists).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.list(kind: kind);
|
||||
await db.batch((b) {
|
||||
for (final p in fresh.all) {
|
||||
b.insert(db.cachedPlaylists, p.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
}
|
||||
});
|
||||
},
|
||||
toResult: (rows) {
|
||||
if (user == null) return PlaylistsList.empty();
|
||||
final owned = rows
|
||||
.where((r) => r.userId == user.id)
|
||||
.map((r) => r.toRef())
|
||||
.toList();
|
||||
final pub = rows
|
||||
.where((r) => r.userId != user.id && r.isPublic)
|
||||
.map((r) => r.toRef())
|
||||
.toList();
|
||||
return PlaylistsList(owned: owned, public: pub);
|
||||
},
|
||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||
);
|
||||
});
|
||||
|
||||
/// Composite shape (playlist + tracks). async* over the playlist watch
|
||||
/// stream + a one-shot tracks fetch per emission.
|
||||
final playlistDetailProvider =
|
||||
FutureProvider.family<PlaylistDetail, String>((ref, id) async {
|
||||
return (await ref.watch(playlistsApiProvider.future)).get(id);
|
||||
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
|
||||
final playlistQuery = db.select(db.cachedPlaylists)
|
||||
..where((t) => t.id.equals(id));
|
||||
|
||||
final tracksQuery = (db.select(db.cachedPlaylistTracks)
|
||||
..where((t) => t.playlistId.equals(id))
|
||||
..orderBy([(t) => drift.OrderingTerm.asc(t.position)]))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedTracks,
|
||||
db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)),
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||
]);
|
||||
|
||||
PlaylistDetail emptyDetail() => PlaylistDetail(
|
||||
playlist: Playlist(
|
||||
id: id,
|
||||
userId: user?.id ?? '',
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
systemVariant: null,
|
||||
trackCount: 0,
|
||||
coverUrl: '',
|
||||
ownerUsername: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
),
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
await for (final playlistRows in playlistQuery.watch()) {
|
||||
if (playlistRows.isEmpty) {
|
||||
if (await ref.read(connectivityProvider.future)) {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id);
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
// watch() re-emits next iteration with populated row.
|
||||
} catch (_) {
|
||||
yield emptyDetail();
|
||||
}
|
||||
} else {
|
||||
yield emptyDetail();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final playlist = playlistRows.first.toRef();
|
||||
final trackRows = await tracksQuery.get();
|
||||
final tracks = trackRows.asMap().entries.map((e) {
|
||||
final r = e.value;
|
||||
final track = r.readTableOrNull(db.cachedTracks);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
final album = r.readTableOrNull(db.cachedAlbums);
|
||||
return PlaylistTrack(
|
||||
position: e.key,
|
||||
trackId: track?.id,
|
||||
title: track?.title ?? '',
|
||||
albumId: album?.id,
|
||||
albumTitle: album?.title ?? '',
|
||||
artistId: artist?.id,
|
||||
artistName: artist?.name ?? '',
|
||||
durationSec: track == null ? 0 : track.durationMs ~/ 1000,
|
||||
streamUrl: null,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||
}
|
||||
});
|
||||
|
||||
final systemPlaylistsStatusProvider =
|
||||
|
||||
Reference in New Issue
Block a user