1646b02ca2
The home screen renders solely from the per-item cached_home_index path (proven on devices for many releases); the legacy snapshot stack was dead weight. - library_providers: remove homeProvider + _encodeHomeData + _albumToJson/_artistToJson/_trackToJson; drop now-unused dart:convert and models/home_data.dart imports - metadata_prefetcher: re-point off homeIndexProvider/HomeIndex — pre-warm artistProvider from the rediscover/last-played artist sections (album/track tiles hydrate their own artist on render) - live_events_dispatcher: homeProvider -> homeIndexProvider so live events still refresh the home screen - db.dart: drop CachedHomeSnapshot (table class + @DriftDatabase entry); schemaVersion 10->11; from<3 createTable -> raw customStatement so the historical step compiles without the generated symbol; from<11 DROP TABLE cached_home_snapshot No test references the removed symbols. db.g.dart regenerated by CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
408 lines
16 KiB
Dart
408 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
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/client.dart';
|
|
import '../api/endpoints/library.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 '../models/album.dart';
|
|
import '../models/artist.dart';
|
|
import '../models/home_index.dart';
|
|
import '../models/track.dart';
|
|
|
|
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
|
/// to the secure-storage `session_token` read — every other endpoint
|
|
/// awaits `dioProvider.future` rather than constructing its own dio.
|
|
///
|
|
/// Riverpod will invalidate any FutureProvider that watches this when
|
|
/// the server URL changes; auth state changes don't need to invalidate
|
|
/// the provider because the resolver re-reads the token on every request.
|
|
final dioProvider = FutureProvider<Dio>((ref) async {
|
|
final url = await ref.watch(serverUrlProvider.future);
|
|
if (url == null || url.isEmpty) {
|
|
throw StateError('no server URL set');
|
|
}
|
|
final storage = ref.watch(secureStorageProvider);
|
|
return ApiClient.buildDio(
|
|
baseUrl: url,
|
|
tokenResolver: () async => storage.read(key: 'session_token'),
|
|
on401: () async => ref.read(authControllerProvider.notifier).clearSession(),
|
|
);
|
|
});
|
|
|
|
final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
|
return LibraryApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
/// Drift-first per-item home index. Reads from cached_home_index
|
|
/// (populated by /api/home/index discovery) and yields a HomeIndex
|
|
/// the screen consumes to lay out section→tile slots. Each tile is
|
|
/// rendered by a per-entity tile provider that hydrates itself.
|
|
///
|
|
/// Section keys mirror the server's response shape so the encode /
|
|
/// decode round-trip is straightforward — the table stores
|
|
/// (section, position, entityType, entityId), and toResult reassembles
|
|
/// the parallel ID lists.
|
|
final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = (db.select(db.cachedHomeIndex)
|
|
..orderBy([
|
|
(t) => drift.OrderingTerm.asc(t.section),
|
|
(t) => drift.OrderingTerm.asc(t.position),
|
|
]))
|
|
.watch();
|
|
return cacheFirst<CachedHomeIndexData, HomeIndex>(
|
|
driftStream: query,
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getHomeIndex();
|
|
// Full replace in a transaction so the watch() sees exactly one
|
|
// post-fetch emission with the merged state. Section ordering is
|
|
// re-asserted on read (orderBy above) so the write order doesn't
|
|
// matter — letting us batch flat instead of section-by-section.
|
|
await db.transaction(() async {
|
|
await db.delete(db.cachedHomeIndex).go();
|
|
await db.batch((b) {
|
|
void rows(String section, String entityType, List<String> ids) {
|
|
for (var i = 0; i < ids.length; i++) {
|
|
b.insert(
|
|
db.cachedHomeIndex,
|
|
CachedHomeIndexCompanion.insert(
|
|
section: section,
|
|
position: i,
|
|
entityType: entityType,
|
|
entityId: ids[i],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums);
|
|
rows('rediscover_albums', 'album', fresh.rediscoverAlbums);
|
|
rows('rediscover_artists', 'artist', fresh.rediscoverArtists);
|
|
rows('most_played_tracks', 'track', fresh.mostPlayedTracks);
|
|
rows('last_played_artists', 'artist', fresh.lastPlayedArtists);
|
|
});
|
|
});
|
|
},
|
|
toResult: (rows) {
|
|
final recentlyAddedAlbums = <String>[];
|
|
final rediscoverAlbums = <String>[];
|
|
final rediscoverArtists = <String>[];
|
|
final mostPlayedTracks = <String>[];
|
|
final lastPlayedArtists = <String>[];
|
|
for (final r in rows) {
|
|
switch (r.section) {
|
|
case 'recently_added_albums':
|
|
recentlyAddedAlbums.add(r.entityId);
|
|
case 'rediscover_albums':
|
|
rediscoverAlbums.add(r.entityId);
|
|
case 'rediscover_artists':
|
|
rediscoverArtists.add(r.entityId);
|
|
case 'most_played_tracks':
|
|
mostPlayedTracks.add(r.entityId);
|
|
case 'last_played_artists':
|
|
lastPlayedArtists.add(r.entityId);
|
|
}
|
|
}
|
|
return HomeIndex(
|
|
recentlyAddedAlbums: recentlyAddedAlbums,
|
|
rediscoverAlbums: rediscoverAlbums,
|
|
rediscoverArtists: rediscoverArtists,
|
|
mostPlayedTracks: mostPlayedTracks,
|
|
lastPlayedArtists: lastPlayedArtists,
|
|
);
|
|
},
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
// SWR: cached layout shows instantly; fresh /api/home/index lands
|
|
// in the background and tiles re-resolve as the table mutates.
|
|
alwaysRefresh: true,
|
|
tag: 'homeIndex',
|
|
);
|
|
});
|
|
|
|
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
|
/// when empty + online, fetches via REST + populates drift, which
|
|
/// re-emits via watch().
|
|
final artistProvider =
|
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
|
final db = ref.watch(appDbProvider);
|
|
// LEFT JOIN cached_albums for cover-URL reconstruction (see
|
|
// CachedArtistAdapter.toRef). First row carries the alphabetically-
|
|
// first album.
|
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
|
.join([
|
|
drift.leftOuterJoin(db.cachedAlbums,
|
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
|
])
|
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
|
return cacheFirst<drift.TypedResult, ArtistRef>(
|
|
driftStream: query.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtist(id);
|
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
|
},
|
|
toResult: (rows) {
|
|
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
|
|
final artist = rows.first.readTable(db.cachedArtists);
|
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
|
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
|
},
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
// No alwaysRefresh: artist rows don't change frequently, and the
|
|
// metadata prefetcher creates many subscriptions in parallel —
|
|
// each silently re-fetching once would saturate the request
|
|
// pipeline behind the user's actual playback request.
|
|
tag: 'artist($id)',
|
|
);
|
|
});
|
|
|
|
final artistAlbumsProvider =
|
|
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
|
|
final db = ref.watch(appDbProvider);
|
|
// Join cached_albums + cached_artists to fill artist_name on each row.
|
|
final query = db.select(db.cachedAlbums).join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
])..where(db.cachedAlbums.artistId.equals(artistId));
|
|
|
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
|
driftStream: query.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtistAlbums(artistId);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
|
|
});
|
|
},
|
|
toResult: (rows) => rows.map((r) {
|
|
final album = r.readTable(db.cachedAlbums);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
return album.toRef(artistName: artist?.name ?? '');
|
|
}).toList(),
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
// SWR: re-fetch the full album list on every visit. Without this,
|
|
// a previously-incomplete drift cache (e.g. user had only opened
|
|
// one album by this artist, so cachedAlbums had just that row)
|
|
// would render forever as a partial list. The prefetcher only
|
|
// warms artistProvider (single row), so this provider isn't
|
|
// mass-instantiated and the storm risk that motivated dropping
|
|
// alwaysRefresh elsewhere doesn't apply here.
|
|
alwaysRefresh: true,
|
|
tag: 'artistAlbums($artistId)',
|
|
);
|
|
});
|
|
|
|
final artistTracksProvider =
|
|
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = db.select(db.cachedTracks).join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
|
drift.leftOuterJoin(db.cachedAlbums,
|
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
|
])..where(db.cachedTracks.artistId.equals(artistId));
|
|
|
|
return cacheFirst<drift.TypedResult, List<TrackRef>>(
|
|
driftStream: query.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtistTracks(artistId);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
|
|
});
|
|
},
|
|
toResult: (rows) => rows.map((r) {
|
|
final track = r.readTable(db.cachedTracks);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
final album = r.readTableOrNull(db.cachedAlbums);
|
|
return track.toRef(
|
|
artistName: artist?.name ?? '',
|
|
albumTitle: album?.title ?? '',
|
|
);
|
|
}).toList(),
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
tag: 'artistTracks($artistId)',
|
|
);
|
|
});
|
|
|
|
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
|
|
/// for reactive updates over both rows.
|
|
final albumProvider = StreamProvider.family<
|
|
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
|
|
final db = ref.watch(appDbProvider);
|
|
|
|
final albumQuery = (db.select(db.cachedAlbums)
|
|
..where((t) => t.id.equals(albumId)))
|
|
.join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
]);
|
|
|
|
final tracksQuery = (db.select(db.cachedTracks)
|
|
..where((t) => t.albumId.equals(albumId))
|
|
..orderBy([
|
|
(t) => drift.OrderingTerm.asc(t.discNumber),
|
|
(t) => drift.OrderingTerm.asc(t.trackNumber),
|
|
]))
|
|
.join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
|
]);
|
|
|
|
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
|
// server genuinely returns zero tracks (or if the fetch fails).
|
|
var fetchAttempted = false;
|
|
// (revalidated flag removed; see SWR note below the yield.)
|
|
|
|
Future<bool> isOnline() async {
|
|
try {
|
|
return await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
|
} catch (_) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Cold-cache fetch: pulls the album + its tracks + any unique
|
|
// artists referenced and writes all three tables in one batch.
|
|
// Returns true on success (drift watch will re-emit), false on
|
|
// failure so the caller can yield an empty result.
|
|
Future<bool> fetchAndPopulate() async {
|
|
try {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api
|
|
.getAlbum(albumId)
|
|
.timeout(const Duration(seconds: 10));
|
|
// Collect every artist mentioned by the album + its tracks so
|
|
// the JOINs that drive both the album header and the track rows
|
|
// have something to bind to. Without this, drift returns null
|
|
// for the artist row and artistName surfaces empty — visible
|
|
// as a missing artist line in the mini player and row list.
|
|
final artists = <String, ArtistRef>{};
|
|
if (fresh.album.artistId.isNotEmpty &&
|
|
fresh.album.artistName.isNotEmpty) {
|
|
artists[fresh.album.artistId] = ArtistRef(
|
|
id: fresh.album.artistId,
|
|
name: fresh.album.artistName,
|
|
);
|
|
}
|
|
for (final t in fresh.tracks) {
|
|
if (t.artistId.isNotEmpty &&
|
|
t.artistName.isNotEmpty &&
|
|
!artists.containsKey(t.artistId)) {
|
|
artists[t.artistId] = ArtistRef(
|
|
id: t.artistId,
|
|
name: t.artistName,
|
|
);
|
|
}
|
|
}
|
|
await db.batch((b) {
|
|
if (artists.isNotEmpty) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
|
|
}
|
|
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
|
|
b.insertAllOnConflictUpdate(db.cachedTracks,
|
|
fresh.tracks.map((t) => t.toDrift()).toList());
|
|
});
|
|
return true;
|
|
} catch (e, st) {
|
|
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
await for (final albumRows in albumQuery.watch()) {
|
|
// Case 1: no album row at all → cold-fetch.
|
|
if (albumRows.isEmpty) {
|
|
if (fetchAttempted) {
|
|
// Already tried and got nothing back.
|
|
yield (
|
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
|
tracks: const <TrackRef>[],
|
|
);
|
|
continue;
|
|
}
|
|
fetchAttempted = true;
|
|
if (!await isOnline()) {
|
|
yield (
|
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
|
tracks: const <TrackRef>[],
|
|
);
|
|
continue;
|
|
}
|
|
final ok = await fetchAndPopulate();
|
|
if (!ok) {
|
|
yield (
|
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
|
tracks: const <TrackRef>[],
|
|
);
|
|
}
|
|
// On success, drift watch re-emits with rows; loop continues.
|
|
continue;
|
|
}
|
|
|
|
final albumRow = albumRows.first;
|
|
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
|
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
|
|
);
|
|
|
|
final trackRows = await tracksQuery.get();
|
|
|
|
// Case 2: album row exists but no tracks. This happens when an
|
|
// upstream provider (artistAlbumsProvider, sync, etc.) populated
|
|
// album rows without their track lists. Trigger the same
|
|
// fetchAndPopulate so the album becomes complete; drift watch
|
|
// re-emits and we land in the populated branch on the next pass.
|
|
if (trackRows.isEmpty && !fetchAttempted) {
|
|
fetchAttempted = true;
|
|
if (await isOnline()) {
|
|
final ok = await fetchAndPopulate();
|
|
if (ok) continue; // wait for watch re-emit
|
|
}
|
|
// Fall through and yield with the album + empty tracks if the
|
|
// fetch failed or we're offline.
|
|
}
|
|
|
|
final tracks = trackRows.map((r) {
|
|
final track = r.readTable(db.cachedTracks);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
return track.toRef(
|
|
artistName: artist?.name ?? '',
|
|
albumTitle: album.title,
|
|
);
|
|
}).toList();
|
|
|
|
// Note: NO SWR here on purpose. Prior code kicked a background
|
|
// refresh on every first cache hit, which combined with the
|
|
// metadata prefetcher meant every prewarmed album id triggered
|
|
// an extra fetch even when drift was already populated. Tracks
|
|
// and album metadata don't change on the same timescale as
|
|
// playlists; a stale read is fine until the user invalidates
|
|
// (pull-to-refresh) or the album is genuinely re-fetched.
|
|
|
|
yield (album: album, tracks: tracks);
|
|
}
|
|
});
|