import 'dart:async'; import 'package:drift/drift.dart' as drift; import 'package:flutter/foundation.dart' show debugPrint; 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'; final playlistsApiProvider = FutureProvider((ref) async { return PlaylistsApi(await ref.watch(dioProvider.future)); }); /// Drift-first per #357 plan C. Returns cached playlists filtered to /// match the `kind` family arg: /// - 'user' → only user-created (systemVariant null), owned by current user /// - 'system' → only system-generated (systemVariant non-null), owned /// - 'all' → everything: own user playlists + own system + others' public /// /// systemVariant column added in drift schema v2 (#357 follow-up); previous /// behavior leaked system playlists into add-to-playlist sheet because we /// couldn't filter locally. final playlistsListProvider = 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); final ownedSysCount = fresh.owned.where((p) => p.systemVariant != null).length; debugPrint( 'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} ' '(system=$ownedSysCount) public=${fresh.public.length}'); 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 filtered = rows.where((r) { if (kind == 'user') return r.systemVariant == null; if (kind == 'system') return r.systemVariant != null; return true; // 'all' }).toList(); final owned = filtered .where((r) => r.userId == user.id) .map((r) => r.toRef()) .toList(); final pub = filtered .where((r) => r.userId != user.id && r.isPublic) .map((r) => r.toRef()) .toList(); final ownedSys = owned.where((p) => p.isSystem).length; final driftSys = filtered.where((r) => r.systemVariant != null).length; debugPrint( 'playlistsListProvider($kind): drift rows=${rows.length} ' 'filtered=${filtered.length} (system=$driftSys) ' 'owned=${owned.length} (system=$ownedSys) pub=${pub.length} ' 'userId=${user.id}'); return PlaylistsList(owned: owned, public: pub); }, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), // Aggregate list — server may add system playlists (For-You / // Discover) that the per-user delta sync doesn't always emit. // Stale-while-revalidate ensures the home tile row reflects the // current server state on every screen visit. alwaysRefresh: true, ); }); /// Composite shape (playlist + tracks). async* over the playlist watch /// stream + a one-shot tracks fetch per emission. final playlistDetailProvider = 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 [], ); Future isOnline() async { try { return await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true); } catch (_) { return true; } } Future fetchAndPopulate() async { try { final api = await ref.read(playlistsApiProvider.future); debugPrint('playlistDetailProvider($id): calling get'); final fresh = await api.get(id).timeout(const Duration(seconds: 10)); debugPrint( 'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing'); // Collect the artist + album rows referenced by these tracks so // the JOINs that build artistName/albumTitle in the row view // have something to bind to. Without this, system-playlist tracks // surface with empty artist/album columns. final artists = {}; final albums = {}; for (final t in fresh.tracks) { final aId = t.artistId; final aName = t.artistName; if (aId != null && aId.isNotEmpty && aName.isNotEmpty) { artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName)); } final lbId = t.albumId; final lbTitle = t.albumTitle; if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) { albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? '')); } } await db.batch((b) { b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), mode: drift.InsertMode.insertOrReplace); // Wipe + re-insert this playlist's track positions so deletions // propagate. Without the wipe, removed tracks would linger in // drift after the playlist mutated server-side. b.deleteWhere( db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); 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, ); } if (artists.isNotEmpty) { b.insertAllOnConflictUpdate( db.cachedArtists, artists.values .map((a) => CachedArtistsCompanion.insert( id: a.id, name: a.name, sortName: a.name, )) .toList(), ); } if (albums.isNotEmpty) { b.insertAllOnConflictUpdate( db.cachedAlbums, albums.values .map((a) => CachedAlbumsCompanion.insert( id: a.id, artistId: a.artistId, title: a.title, sortTitle: a.title, )) .toList(), ); } }); debugPrint( 'playlistDetailProvider($id): drift write done; awaiting watch re-emit'); return true; } catch (e, st) { debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st'); return false; } } // Once-per-subscription guard so an empty server response doesn't // cause repeated re-fetches. var fetchAttempted = false; var revalidated = false; await for (final playlistRows in playlistQuery.watch()) { if (playlistRows.isEmpty) { debugPrint('playlistDetailProvider($id): drift miss, cold-fetching'); if (fetchAttempted) { yield emptyDetail(); continue; } fetchAttempted = true; if (!await isOnline()) { yield emptyDetail(); continue; } final ok = await fetchAndPopulate(); if (!ok) yield emptyDetail(); continue; } debugPrint( 'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)'); final playlist = playlistRows.first.toRef(); final trackRows = await tracksQuery.get(); // Same pattern as albumProvider: playlist row exists but no tracks // (e.g., playlistsListProvider wrote the row, sync hasn't carried // tracks for system playlists). Trigger the same fetch, drift // watch re-emits with populated tracks. if (trackRows.isEmpty && !fetchAttempted) { debugPrint( 'playlistDetailProvider($id): playlist hit but no tracks; fetching'); fetchAttempted = true; if (await isOnline()) { final ok = await fetchAndPopulate(); if (ok) continue; // wait for watch re-emit } } 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); // SWR: first complete cache hit kicks one background refresh. if (!revalidated && trackRows.isNotEmpty) { revalidated = true; if (await isOnline()) { unawaited(fetchAndPopulate()); } } } }); /// Lightweight tuples used inside fetchAndPopulate to dedupe artist /// and album rows referenced by playlist tracks before we batch-write /// them to drift. class ArtistRefRow { ArtistRefRow(this.id, this.name); final String id; final String name; } class AlbumRefRow { AlbumRefRow(this.id, this.title, this.artistId); final String id; final String title; final String artistId; } final systemPlaylistsStatusProvider = FutureProvider((ref) async { final dio = await ref.watch(dioProvider.future); return MeApi(dio).systemPlaylistsStatus(); });