import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; 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); // Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs // every rebuild, so insertOrReplace alone leaves stale rows in // drift. Tapping one of those stale tiles 404s. Delete every // owned drift row whose id isn't in the fresh response, then // upsert the fresh set. final freshOwnedIds = fresh.owned.map((p) => p.id).toSet(); await db.batch((b) { if (user != null) { b.deleteWhere(db.cachedPlaylists, (t) { return t.userId.equals(user.id) & t.id.isNotIn(freshOwnedIds); }); } 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(); 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); final fresh = await api.get(id).timeout(const Duration(seconds: 10)); // Collect the track + artist + album rows referenced by these // playlist entries. Without writing the cachedTracks rows // themselves, the detail-screen JOIN against cachedTracks // returns null on every row (only the playlist_tracks join // succeeds), so the UI shows a list with empty titles and // unplayable tracks. final tracks = {}; final artists = {}; final albums = {}; for (final t in fresh.tracks) { final tId = t.trackId; if (tId != null && tId.isNotEmpty) { tracks.putIfAbsent( tId, () => _TrackRefRow( id: tId, title: t.title, albumId: t.albumId ?? '', artistId: t.artistId ?? '', durationSec: t.durationSec, ), ); } 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 (tracks.isNotEmpty) { // Insert/update cachedTracks so the detail screen's JOIN // produces real titles + durations. We don't have // track_number / disc_number on the wire (PlaylistTrack // omits them), so they default to 0 — UI doesn't surface // them on this screen so it's fine. b.insertAllOnConflictUpdate( db.cachedTracks, tracks.values .map((t) => CachedTracksCompanion.insert( id: t.id, albumId: t.albumId, artistId: t.artistId, title: t.title, durationMs: drift.Value(t.durationSec * 1000), )) .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(), ); } }); return true; } catch (e, st) { // 404 = the playlist row in drift is stale (BuildSystemPlaylists // rotates UUIDs on each rebuild, so old For-You / Songs-Like // tiles can outlive the actual server-side playlist). Wipe the // stale row + its track positions so the home tile disappears // on next render and we don't keep trying. if (e is DioException && e.response?.statusCode == 404) { await db.batch((b) { b.deleteWhere( db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id)); }); return false; } 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; await for (final playlistRows in playlistQuery.watch()) { if (playlistRows.isEmpty) { if (fetchAttempted) { yield emptyDetail(); continue; } fetchAttempted = true; if (!await isOnline()) { yield emptyDetail(); continue; } final ok = await fetchAndPopulate(); if (!ok) yield emptyDetail(); continue; } 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) { 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); // No SWR refresh here. The aggregate playlistsListProvider does // alwaysRefresh (system playlists rotate UUIDs), but per-detail // refresh on every visit was multiplying with the prefetcher's // parallel fetches and starving user-initiated playback. Pull- // to-refresh on the detail page invalidates the provider, which // is the right path for an explicit refresh. } }); /// 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; } class _TrackRefRow { _TrackRefRow({ required this.id, required this.title, required this.albumId, required this.artistId, required this.durationSec, }); final String id; final String title; final String albumId; final String artistId; final int durationSec; } /// Drift-first per #357 pattern. Reads from cached_system_playlists_ /// status (single-row JSON blob populated by /api/me/system-playlists- /// status). Home Playlists row reads this every render to choose /// between real cards and "building / pending / failed" placeholders /// — drift-first means the row paints with the prior status instantly /// rather than flickering through SystemPlaylistsStatus.empty() while /// the REST round-trip resolves. SWR refresh on every visit keeps it /// current. final systemPlaylistsStatusProvider = StreamProvider((ref) { final db = ref.watch(appDbProvider); return cacheFirst( driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(), fetchAndPopulate: () async { final dio = await ref.read(dioProvider.future); final fresh = await MeApi(dio).systemPlaylistsStatus(); await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate( CachedSystemPlaylistsStatusCompanion.insert( json: jsonEncode({ 'in_flight': fresh.inFlight, 'last_run_at': fresh.lastRunAt, 'last_error': fresh.lastError, }), updatedAt: drift.Value(DateTime.now()), ), ); }, toResult: (rows) => rows.isEmpty ? SystemPlaylistsStatus.empty() : SystemPlaylistsStatus.fromJson( jsonDecode(rows.first.json) as Map), isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), alwaysRefresh: true, tag: 'systemPlaylistsStatus', ); });