diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index d3b97ca4..8a1f82cf 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -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((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((ref, kind) async { - return (await ref.watch(playlistsApiProvider.future)).list(kind: kind); + StreamProvider.family((ref, kind) { + final db = ref.watch(appDbProvider); + final user = ref.watch(authControllerProvider).value; + + return cacheFirst( + 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((ref, id) async { - return (await ref.watch(playlistsApiProvider.future)).get(id); + StreamProvider.family((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 =