2a18e91c39
Four-part change to push more surfaces onto the drift cache and eliminate cold-tab-visit latency on the Library screen. * **Library Artists tab** — _libraryArtistsProvider migrates from REST-paginated AsyncNotifier with infinite-scroll loadMore to a drift-first StreamProvider over cached_artists ordered by sortName. Sync already populates the full set; cacheFirst's fetchAndPopulate covers the fresh-install + sync-not-yet-done cold case via /api/artists?limit=1000. SWR refresh on every visit. GridView.builder lazily realizes only visible cells so loading the full list up front is fine for typical libraries. loadMore + NotificationListener gone. * **Library Albums tab** — same migration, drift-first over cached_albums joined with cached_artists for the artistName field. * **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot) for the home Playlists row's "building / pending / failed" placeholder logic. Drift-first means the row paints with the prior status instantly instead of flickering through SystemPlaylistsStatus.empty() while the REST call resolves. * **Library screen tab pre-warm** — ref.listen on all 5 tab providers in _LibraryScreenState.build subscribes them upfront so swiping between tabs feels instant rather than each tab paying its own cold-cache cost on first visit. cacheFirst handles dedupe of concurrent fetchAndPopulate triggers. Test mock updated for the StreamProvider type change on systemPlaylistsStatusProvider.
404 lines
14 KiB
Dart
404 lines
14 KiB
Dart
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<PlaylistsApi>((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<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);
|
|
|
|
// 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<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 [],
|
|
);
|
|
|
|
Future<bool> isOnline() async {
|
|
try {
|
|
return await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
|
} catch (_) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Future<bool> 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 = <String, _TrackRefRow>{};
|
|
final artists = <String, ArtistRefRow>{};
|
|
final albums = <String, AlbumRefRow>{};
|
|
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<SystemPlaylistsStatus>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
|
|
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<String, dynamic>),
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
alwaysRefresh: true,
|
|
tag: 'systemPlaylistsStatus',
|
|
);
|
|
});
|